mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #386 from irisitymichaelgrundberg/feat/codex-resume
Merging with the phone-overview resumeId fix and the two unified-list doc passages applied on master.
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* @fileoverview Scan `~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-*.jsonl` for Past
|
||||
* Sessions rows, the codex analog of what `scanOmpSessionsHistory()`
|
||||
* (omp-transcript.ts) does for omp and `scanProjectDir()` (session-routes.ts)
|
||||
* does for Claude's own `~/.claude/projects` transcripts.
|
||||
*
|
||||
* Without this a codex conversation is invisible to Codeman the moment its
|
||||
* session record goes away, even though codex itself never forgot it: the
|
||||
* unified list is built from `~/.claude/projects` plus omp's own store, and
|
||||
* codex writes to neither. A user who wanted to pick a codex thread back up had
|
||||
* to find its id by hand and pass `codexConfig.resumeSessionId` to the API.
|
||||
*
|
||||
* ## Why this reads windows rather than whole files
|
||||
*
|
||||
* An omp session file is the conversation only, so its scanner reads each file
|
||||
* whole. A codex rollout is not comparable: it carries every reasoning block and
|
||||
* every tool call, and its `session_meta` line alone embeds the full base
|
||||
* instructions. Measured on a real store of 519 rollouts, the median file is
|
||||
* 407 KiB, the 90th percentile 1.3 MiB and the largest 25 MiB, for 381 MiB in
|
||||
* total. So this reads a head window for the identity and the opening prompt,
|
||||
* and a tail window for the most recent one.
|
||||
*
|
||||
* The head budget is 128 KiB because `session_meta` runs to roughly 19 KiB and
|
||||
* the first real user message lands near 69 KiB behind it, both measured on
|
||||
* codex 0.152.1.
|
||||
*
|
||||
* ## Where the prompt text comes from
|
||||
*
|
||||
* Codex has emitted user input under three shapes, and this reads all of them,
|
||||
* preferring the ones that carry real input only:
|
||||
*
|
||||
* - `event_msg` / `item_completed` with an `item.type` of `UserMessage`, which
|
||||
* is what codex 0.152.1 writes.
|
||||
* - `event_msg` / `user_message`, which older versions wrote.
|
||||
* - `response_item` rows with `role: 'user'`, the last resort. These mix real
|
||||
* input with injected context (AGENTS.md, environment context, compaction
|
||||
* summaries), so they are read only when neither shape above appears, and
|
||||
* the obvious injections are dropped.
|
||||
*
|
||||
* @module codex-transcript
|
||||
*/
|
||||
|
||||
import { open, readdir, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { LRUMap } from './utils/lru-map.js';
|
||||
|
||||
/** Covers `session_meta` (~19 KiB) plus the first user message (~69 KiB behind it). */
|
||||
const HEAD_BYTES = 131072;
|
||||
|
||||
/** Enough to hold the last few turns' worth of lines without re-reading the file. */
|
||||
const TAIL_BYTES = 65536;
|
||||
|
||||
/**
|
||||
* Newest rollouts to REPORT. Counted in emitted rows, not files scanned: the
|
||||
* store is mostly sub-agent threads this never returns, so capping files first
|
||||
* would spend the budget on rows nobody sees.
|
||||
*/
|
||||
const MAX_ROLLOUTS = 400;
|
||||
|
||||
/**
|
||||
* How many emitted rows also get a tail read for `lastPrompt`. The head read is
|
||||
* cached (see below) but the tail cannot be, because appending to a rollout is
|
||||
* exactly what changes it, so this is the one genuinely per-request cost and it
|
||||
* stays bounded. Counted in emitted rows for the same reason as above — against
|
||||
* file index a store of sub-agent threads spends the whole budget before the
|
||||
* first row that needed it.
|
||||
*/
|
||||
const MAX_TAIL_READS = 100;
|
||||
|
||||
/** Directory nesting under `sessions/` is year/month/day; stop well past that. */
|
||||
const MAX_WALK_DEPTH = 5;
|
||||
|
||||
/** A rollout shorter than this cannot hold a complete `session_meta` line. */
|
||||
const MIN_ROLLOUT_BYTES = 100;
|
||||
|
||||
export interface CodexHistorySession {
|
||||
/** The rollout's own thread id — the token `codex resume <id>` expects. */
|
||||
sessionId: string;
|
||||
/**
|
||||
* `session_meta.originator`, which codex stamps from
|
||||
* CODEX_INTERNAL_ORIGINATOR_OVERRIDE — `codeman_<sessionId>` for every pane
|
||||
* Codeman spawns. The only link between a FRESH codex pane and the rollout it
|
||||
* is writing, since such a pane knows no thread id of its own.
|
||||
*/
|
||||
originator?: string;
|
||||
workingDir: string;
|
||||
sizeBytes: number;
|
||||
/** ISO timestamp, from the file's own mtime. */
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
lastPrompt?: string;
|
||||
}
|
||||
|
||||
/** The half of a rollout that never changes once codex has written it. */
|
||||
interface RolloutIdentity {
|
||||
threadId?: string;
|
||||
cwd?: string;
|
||||
/** `'subagent'` marks a thread codex spawned for itself. */
|
||||
threadSource?: string;
|
||||
/** `codeman_<sessionId>` for a pane Codeman spawned; codex's own default otherwise. */
|
||||
originator?: string;
|
||||
firstPrompt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `session_meta` is written once and never rewritten — the same fact
|
||||
* `readCodexRolloutMetaCached()` in session-routes.ts relies on — so a path's
|
||||
* identity is cached, and a rescan costs a `stat` per file plus head reads for
|
||||
* rollouts this process has not seen before.
|
||||
*
|
||||
* ⚠️ The first user message is NOT written up front: codex writes it when the
|
||||
* user submits. Caching before then pins `firstPrompt: undefined` for the life
|
||||
* of the process, and every scan of the home screen, the command palette and the
|
||||
* search-index refresh can land in that window — so the row reads as having no
|
||||
* prompt until a restart. `shouldCacheIdentity()` is the guard.
|
||||
*
|
||||
* Bounded, unlike a plain Map: this process runs for days and every sub-agent
|
||||
* rollout adds an entry. Same reason and same size as `codexRolloutMetaCache`.
|
||||
*/
|
||||
const identityCache = new LRUMap<string, RolloutIdentity>({ maxSize: 4096 });
|
||||
|
||||
/**
|
||||
* Is this identity settled enough to keep?
|
||||
*
|
||||
* A known `firstPrompt` settles it. So does a head read that FILLED its window,
|
||||
* which means the prompt is genuinely not in the first `HEAD_BYTES` rather than
|
||||
* not written yet. A short file with no prompt is the ambiguous case — codex is
|
||||
* still to write one — so that one is re-read next scan.
|
||||
*/
|
||||
function shouldCacheIdentity(identity: RolloutIdentity, fileSize: number): boolean {
|
||||
if (!identity.threadId) return false;
|
||||
return identity.firstPrompt !== undefined || fileSize >= HEAD_BYTES;
|
||||
}
|
||||
|
||||
function codexSessionsRoot(): string {
|
||||
const home = process.env.CODEX_HOME || join(homedir(), '.codex');
|
||||
return join(home, 'sessions');
|
||||
}
|
||||
|
||||
/** Read at most `bytes` from the front of a file. Returns '' when unreadable. */
|
||||
async function readHead(path: string, bytes: number): Promise<string> {
|
||||
const fh = await open(path, 'r').catch(() => null);
|
||||
if (!fh) return '';
|
||||
try {
|
||||
const buf = Buffer.alloc(bytes);
|
||||
const { bytesRead } = await fh.read(buf, 0, bytes, 0);
|
||||
return buf.subarray(0, bytesRead).toString('utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
} finally {
|
||||
await fh.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read at most `bytes` from the end of a file, dropping the leading partial
|
||||
* line so every line handed back parses.
|
||||
*/
|
||||
async function readTail(path: string, size: number, bytes: number): Promise<string> {
|
||||
const fh = await open(path, 'r').catch(() => null);
|
||||
if (!fh) return '';
|
||||
try {
|
||||
const want = Math.min(bytes, size);
|
||||
const buf = Buffer.alloc(want);
|
||||
const { bytesRead } = await fh.read(buf, 0, want, size - want);
|
||||
const text = buf.subarray(0, bytesRead).toString('utf-8');
|
||||
if (want >= size) return text; // whole file, nothing was cut
|
||||
const nl = text.indexOf('\n');
|
||||
return nl === -1 ? '' : text.slice(nl + 1);
|
||||
} catch {
|
||||
return '';
|
||||
} finally {
|
||||
await fh.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** Flatten codex's message content, which is a string or an array of text blocks. */
|
||||
function contentText(content: unknown): string {
|
||||
if (typeof content === 'string') return content.trim();
|
||||
if (!Array.isArray(content)) return '';
|
||||
return content
|
||||
.filter(
|
||||
(b): b is { text: string } => !!b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string'
|
||||
)
|
||||
.map((b) => b.text)
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** One line's user-prompt text, whichever of the three shapes it is. */
|
||||
function userPromptFromLine(entry: {
|
||||
type?: string;
|
||||
payload?: {
|
||||
type?: string;
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
message?: unknown;
|
||||
item?: { type?: string; content?: unknown };
|
||||
};
|
||||
}): { text: string; injectionProne: boolean } | null {
|
||||
const p = entry.payload;
|
||||
if (!p) return null;
|
||||
|
||||
if (entry.type === 'event_msg' && p.type === 'item_completed' && p.item?.type === 'UserMessage') {
|
||||
const text = contentText(p.item.content);
|
||||
return text ? { text, injectionProne: false } : null;
|
||||
}
|
||||
if (entry.type === 'event_msg' && p.type === 'user_message') {
|
||||
const text = typeof p.message === 'string' ? p.message.trim() : contentText(p.message);
|
||||
return text ? { text, injectionProne: false } : null;
|
||||
}
|
||||
if (entry.type === 'response_item' && p.role === 'user') {
|
||||
const text = contentText(p.content);
|
||||
return text ? { text, injectionProne: true } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injected context rather than something the user typed. Codex prepends the
|
||||
* repository's AGENTS.md and wraps environment context in a tag, and both arrive
|
||||
* as `response_item` user rows.
|
||||
*/
|
||||
function isInjectedContext(text: string): boolean {
|
||||
return text.startsWith('#') || text.startsWith('<');
|
||||
}
|
||||
|
||||
/** Collapse to one line and cap, so a row carries a title rather than an essay. */
|
||||
function asPreview(text: string): string {
|
||||
const flat = text.replace(/\s+/g, ' ').trim();
|
||||
return flat.length > 200 ? `${flat.slice(0, 200)}…` : flat;
|
||||
}
|
||||
|
||||
/** Parse a head window into the facts about a rollout that never change. */
|
||||
function parseIdentity(head: string): RolloutIdentity {
|
||||
const out: RolloutIdentity = {};
|
||||
let fallback: string | undefined;
|
||||
for (const line of head.split('\n')) {
|
||||
if (!line) continue;
|
||||
let entry: {
|
||||
type?: string;
|
||||
payload?: {
|
||||
id?: string;
|
||||
session_id?: string;
|
||||
cwd?: string;
|
||||
thread_source?: string;
|
||||
originator?: string;
|
||||
type?: string;
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
message?: unknown;
|
||||
item?: { type?: string; content?: unknown };
|
||||
};
|
||||
};
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue; // truncated tail of the window, or a malformed line
|
||||
}
|
||||
const p = entry.payload;
|
||||
if (entry.type === 'session_meta' && p) {
|
||||
out.threadId ??= p.id || p.session_id;
|
||||
out.cwd ??= p.cwd;
|
||||
out.threadSource ??= p.thread_source;
|
||||
out.originator ??= p.originator;
|
||||
} else if (entry.type === 'turn_context' && p) {
|
||||
out.cwd ??= p.cwd;
|
||||
}
|
||||
if (out.firstPrompt) continue;
|
||||
const prompt = userPromptFromLine(entry);
|
||||
if (!prompt) continue;
|
||||
if (!prompt.injectionProne) {
|
||||
out.firstPrompt = asPreview(prompt.text);
|
||||
} else if (!fallback && !isInjectedContext(prompt.text)) {
|
||||
fallback = asPreview(prompt.text);
|
||||
}
|
||||
}
|
||||
out.firstPrompt ??= fallback;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The most recent user prompt in a tail window, or undefined. */
|
||||
function parseLastPrompt(tail: string): string | undefined {
|
||||
let best: string | undefined;
|
||||
let fallback: string | undefined;
|
||||
for (const line of tail.split('\n')) {
|
||||
if (!line) continue;
|
||||
try {
|
||||
const prompt = userPromptFromLine(JSON.parse(line));
|
||||
if (!prompt) continue;
|
||||
if (!prompt.injectionProne) best = asPreview(prompt.text);
|
||||
else if (!isInjectedContext(prompt.text)) fallback = asPreview(prompt.text);
|
||||
} catch {
|
||||
// Malformed line — keep scanning.
|
||||
}
|
||||
}
|
||||
return best ?? fallback;
|
||||
}
|
||||
|
||||
/** Every rollout file under `sessions/`, newest first. */
|
||||
async function listRollouts(root: string): Promise<Array<{ path: string; mtimeMs: number; size: number }>> {
|
||||
const files: Array<{ path: string; mtimeMs: number; size: number }> = [];
|
||||
const walk = async (dir: string, depth: number): Promise<void> => {
|
||||
if (depth > MAX_WALK_DEPTH) return;
|
||||
const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
|
||||
if (!entries) return;
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(full, depth + 1);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
||||
const st = await stat(full).catch(() => null);
|
||||
if (!st || st.size < MIN_ROLLOUT_BYTES) continue;
|
||||
files.push({ path: full, mtimeMs: st.mtimeMs, size: st.size });
|
||||
}
|
||||
};
|
||||
await walk(root, 0);
|
||||
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex conversations on this host, newest first, for the unified session list.
|
||||
*
|
||||
* Sub-agent threads are left out: codex spawns them for itself, they are not
|
||||
* something a person picks back up, and on a real store they outnumber the
|
||||
* threads that are.
|
||||
*/
|
||||
export async function scanCodexSessionsHistory(): Promise<CodexHistorySession[]> {
|
||||
const files = await listRollouts(codexSessionsRoot());
|
||||
const out: CodexHistorySession[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (out.length >= MAX_ROLLOUTS) break;
|
||||
|
||||
let identity = identityCache.get(file.path);
|
||||
if (!identity) {
|
||||
identity = parseIdentity(await readHead(file.path, HEAD_BYTES));
|
||||
if (shouldCacheIdentity(identity, file.size)) identityCache.set(file.path, identity);
|
||||
}
|
||||
if (!identity.threadId || identity.threadSource === 'subagent') continue;
|
||||
// A row with no directory has nowhere to resume INTO, and emitting an empty
|
||||
// one makes a click post `workingDir: ''`. omp drops such a row; so does this.
|
||||
if (!identity.cwd) continue;
|
||||
|
||||
const lastPrompt =
|
||||
out.length < MAX_TAIL_READS ? parseLastPrompt(await readTail(file.path, file.size, TAIL_BYTES)) : undefined;
|
||||
|
||||
out.push({
|
||||
sessionId: identity.threadId,
|
||||
originator: identity.originator,
|
||||
workingDir: identity.cwd,
|
||||
sizeBytes: file.size,
|
||||
lastModified: new Date(file.mtimeMs).toISOString(),
|
||||
firstPrompt: identity.firstPrompt,
|
||||
lastPrompt: lastPrompt ?? identity.firstPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which codex thread each Codeman-spawned pane is writing, keyed by Codeman
|
||||
* session id.
|
||||
*
|
||||
* Codeman spawns every codex pane with
|
||||
* CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_<sessionId>, and codex stamps that
|
||||
* into `session_meta.originator`. That is the ONLY link between a fresh codex
|
||||
* pane and the rollout it is writing: such a pane knows no thread id of its own,
|
||||
* so it cannot be folded into its own Past-Sessions row from its own side.
|
||||
*
|
||||
* Newest wins. `/new` typed inside the codex TUI leaves several rollouts sharing
|
||||
* one originator, and the pane is on the most recent — so this expects `rows`
|
||||
* newest-first, as `scanCodexSessionsHistory()` returns them.
|
||||
*/
|
||||
export function codexThreadBySessionId(rows: CodexHistorySession[]): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const row of rows) {
|
||||
const owner = /^codeman_(.+)$/.exec(row.originator ?? '')?.[1];
|
||||
if (owner && !out.has(owner)) out.set(owner, row.sessionId);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Test seam: drop the per-path identity cache. */
|
||||
export function __clearCodexIdentityCache(): void {
|
||||
identityCache.clear();
|
||||
}
|
||||
@@ -2,12 +2,16 @@
|
||||
* @fileoverview Pure merge/filter logic for the unified session list (COD-121).
|
||||
*
|
||||
* Combines four read-only views of a session — live (in-memory `Session`),
|
||||
* persisted (`state.json`), transcript history (`~/.claude/projects`), and the
|
||||
* lifecycle audit log — plus mux process stats, into one de-duplicated list
|
||||
* keyed by sessionId. Transcript-history rows are keyed by the Claude
|
||||
* conversation UUID (the `.jsonl` filename stem), which diverges from the
|
||||
* Codeman id for resumed sessions — an alias map (claudeSessionId → Codeman id,
|
||||
* built from the live/persisted views) folds them into the owning session item.
|
||||
* persisted (`state.json`), transcript history, and the lifecycle audit log —
|
||||
* plus mux process stats, into one de-duplicated list keyed by sessionId.
|
||||
*
|
||||
* Transcript history is not one source but three, because the CLIs keep their
|
||||
* conversations in their own stores: Claude's `~/.claude/projects`, omp's
|
||||
* `~/.omp/agent/sessions` and codex's `~/.codex/sessions`. Each row is keyed by
|
||||
* whatever id that CLI names the conversation with, which diverges from the
|
||||
* Codeman id for a resumed session and for every non-Claude one — an alias map
|
||||
* (claudeSessionId → Codeman id, built from the live/persisted views) folds them
|
||||
* into the owning session item.
|
||||
* Higher-precedence sources overwrite scalar fields when present
|
||||
* (history < lifecycle < persisted < live), while the `sources` array
|
||||
* always accumulates every contributing view. A "meaningfulness floor" drops
|
||||
@@ -39,6 +43,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 +109,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 +204,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;
|
||||
}
|
||||
|
||||
+37
-19
@@ -724,13 +724,20 @@ export class Session extends EventEmitter {
|
||||
this._wireActivityAt = config.lastActivityAt || Date.now();
|
||||
this._wireActivitySettleUntil = config.lastActivityAt ? Date.now() + WIRE_ACTIVITY_SETTLE_MS : 0;
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
// For omp, `claudeSessionId` doubles as the generic "external transcript id"
|
||||
// alias key mergeUnifiedSessions() folds a history row into its owning
|
||||
// session by: omp mints its OWN uuid, unrelated to this Codeman id, so
|
||||
// without this an omp conversation's Past-Sessions row (keyed by omp's
|
||||
// id) would never merge with its own live/persisted row (keyed by this
|
||||
// id) — it would just show up a second time.
|
||||
this._claudeSessionId = config.resumeSessionId || config.ompConfig?.resumeSessionId || this.id;
|
||||
// For omp and codex, `claudeSessionId` doubles as the generic "external
|
||||
// transcript id" alias key mergeUnifiedSessions() folds a history row into
|
||||
// its owning session by: each mints its OWN thread id, unrelated to this
|
||||
// Codeman id, so without this the conversation's Past-Sessions row (keyed by
|
||||
// that thread id) would never merge with its own live/persisted row (keyed
|
||||
// by this id) — it would just show up a second time. For codex a duplicate
|
||||
// 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.
|
||||
//
|
||||
// This covers a RESUMED codex session, which knows its thread id up front. A
|
||||
// fresh one learns its id only once codex writes the rollout, so it is folded
|
||||
// from the other side — see the originator stamping in `gatherUnifiedInputs()`.
|
||||
this._claudeSessionId =
|
||||
config.resumeSessionId || config.ompConfig?.resumeSessionId || config.codexConfig?.resumeSessionId || this.id;
|
||||
// Restored from state.json on boot recovery. start() resets _claudeSessionId
|
||||
// to the launch id even when re-attaching to a mux session whose CLI has
|
||||
// moved on (a `/clear` before the restart), so this anchor is what lets the
|
||||
@@ -2068,16 +2075,23 @@ export class Session extends EventEmitter {
|
||||
// this to omp's own session uuid — that already-resolved id must win
|
||||
// over the generic `this.id` fallback, or this line clobbers it back
|
||||
// to the Codeman id
|
||||
// on every single respawn.
|
||||
// on every single respawn. codex needs the same fallback for the same
|
||||
// reason: its thread id lives in `_codexConfig`, so without it every
|
||||
// respawn drops a resumed codex session's alias and its Past-Sessions
|
||||
// row springs back as a duplicate that still resumes.
|
||||
// ⚠️ A RESTORED mux session is the one case where the launch id is a
|
||||
// lie: the CLI never stopped, so a `/clear` before the Codeman restart
|
||||
// already moved it to a conversation `this.id` knows nothing about. The
|
||||
// persisted chain's tail is that conversation, reported first-hand by
|
||||
// the CLI's own hook, so it outranks the fallback here. A NEW pane has
|
||||
// an empty chain and falls through to exactly today's expression.
|
||||
// the CLI's own hook, so it outranks every fallback here. A NEW pane has
|
||||
// an empty chain and falls through to the resume/alias fallbacks.
|
||||
restoredConversation = isRestored ? this._claudeSessionChain[this._claudeSessionChain.length - 1] : undefined;
|
||||
this._claudeSessionId =
|
||||
restoredConversation || this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id;
|
||||
restoredConversation ||
|
||||
this._resumeSessionId ||
|
||||
this._ompConfig?.resumeSessionId ||
|
||||
this._codexConfig?.resumeSessionId ||
|
||||
this.id;
|
||||
|
||||
// For NEW mux sessions: wait for readiness then clean buffer
|
||||
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
|
||||
@@ -2178,15 +2192,19 @@ export class Session extends EventEmitter {
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
// Mirrors the mux branch above and must not clobber it: this line runs
|
||||
// unconditionally after both the mux and direct-PTY paths, so it also needs
|
||||
// the ompConfig fallback or it stomps the mux branch's correctly-resolved
|
||||
// OMP alias back to this.id on every mux/plain-reattach boot recovery
|
||||
// (the "third reset point" — see DECISIONS.md). For the same reason it needs
|
||||
// `restoredConversation`: on a RESTORED mux attach the CLI never stopped and
|
||||
// may have `/clear`ed before the restart, so the launch id is a lie and the
|
||||
// chain's tail is the live conversation. Empty on every other path, which
|
||||
// leaves this expression exactly as it was.
|
||||
// the ompConfig and codexConfig fallbacks or it stomps the mux branch's
|
||||
// correctly-resolved OMP/codex alias back to this.id on every mux/plain-
|
||||
// reattach boot recovery (the "third reset point" — see DECISIONS.md).
|
||||
// For the same reason it needs `restoredConversation`: on a RESTORED mux
|
||||
// attach the CLI never stopped and may have `/clear`ed before the restart,
|
||||
// so the launch id is a lie and the chain's tail is the live conversation.
|
||||
// It is empty on every other path, so those paths keep the alias chain.
|
||||
this._claudeSessionId =
|
||||
restoredConversation || this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id;
|
||||
restoredConversation ||
|
||||
this._resumeSessionId ||
|
||||
this._ompConfig?.resumeSessionId ||
|
||||
this._codexConfig?.resumeSessionId ||
|
||||
this.id;
|
||||
|
||||
this._pid = this.ptyProcess.pid;
|
||||
console.log('[Session] Interactive PTY spawned with PID:', this._pid);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -804,7 +804,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);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
@@ -2982,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(() => {});
|
||||
}
|
||||
|
||||
@@ -148,6 +148,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, codexThreadBySessionId } from '../../codex-transcript.js';
|
||||
import {
|
||||
getLastTranscriptResponse,
|
||||
isExternalCliTranscriptMode,
|
||||
@@ -4164,6 +4165,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,
|
||||
@@ -4172,7 +4180,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,
|
||||
}));
|
||||
@@ -4240,6 +4248,49 @@ 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 {
|
||||
const codexRows = await scanCodexSessionsHistory();
|
||||
for (const h of codexRows) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
}
|
||||
|
||||
// Mux process stats (best-effort; guard against mocks lacking the method).
|
||||
let mux: MuxStatInput[] = [];
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @fileoverview A resumed codex session must keep its thread-id alias across
|
||||
* `start()`, not just at construction.
|
||||
*
|
||||
* `claudeSessionId` doubles as the generic "external transcript id" the unified
|
||||
* list folds a Past-Sessions row into its owning session by. For codex that id
|
||||
* is the rollout's thread id, and losing it is not cosmetic: the stale row stays
|
||||
* in PAST and still resumes, so clicking it starts a SECOND `codex resume` on a
|
||||
* thread already open in the live pane.
|
||||
*
|
||||
* The bug this pins: the alias was wired into the constructor only. `start()`
|
||||
* recomputes `claudeSessionId` at two further points — the mux branch and the
|
||||
* unconditional "third reset point" that runs after both the mux and direct-PTY
|
||||
* paths — and both listed only Claude's `resumeSessionId` and omp's. For codex
|
||||
* both are undefined, so every mux reattach and every boot recovery reset the
|
||||
* alias back to the Codeman id and the duplicate came back. The existing comment
|
||||
* at the third reset point already warned that omitting omp's fallback there
|
||||
* "stomps the mux branch's correctly-resolved OMP alias"; codex needed the same.
|
||||
*
|
||||
* Mirrors `test/omp-fresh-run-no-resume.test.ts`, which drives a real `Session`
|
||||
* against the in-memory tmux layer that vitest substitutes.
|
||||
*/
|
||||
import { mkdirSync, rmSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { Session } from '../src/session.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
|
||||
describe('codex: a resumed thread id survives start()', () => {
|
||||
const workingDir = join(homedir(), 'codeman-cases', 'codex-resume-alias');
|
||||
const THREAD_ID = '01a060f0-0361-7f91-abde-b283020db0d7';
|
||||
const sessions: Session[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const s of sessions.splice(0)) s.stop();
|
||||
rmSync(workingDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeSession(useMux: boolean): Session {
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
mode: 'codex',
|
||||
codexConfig: { resumeSessionId: THREAD_ID },
|
||||
mux: new TmuxManager(),
|
||||
useMux,
|
||||
});
|
||||
sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
it('carries the thread id from construction', () => {
|
||||
expect(makeSession(true).claudeSessionId).toBe(THREAD_ID);
|
||||
});
|
||||
|
||||
it('still carries it after starting under mux', async () => {
|
||||
const session = makeSession(true);
|
||||
await session.startInteractive();
|
||||
expect(session.claudeSessionId).toBe(THREAD_ID);
|
||||
});
|
||||
|
||||
it('refuses to start without mux at all, so the mux path is the only one to cover', async () => {
|
||||
// codex declares `requiresMux`, so there is no direct-PTY codex session for
|
||||
// the third reset point to run against on its own — the assertion above is
|
||||
// the whole surface.
|
||||
await expect(makeSession(false).startInteractive()).rejects.toThrow(/require tmux/i);
|
||||
});
|
||||
|
||||
it('a fresh codex session keeps the Codeman id, having no thread of its own', async () => {
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
const session = new Session({ workingDir, mode: 'codex', mux: new TmuxManager(), useMux: true });
|
||||
sessions.push(session);
|
||||
|
||||
await session.startInteractive();
|
||||
|
||||
// Nothing to alias to yet — codex has not written the rollout. Such a
|
||||
// session is folded from the other side, by originator (codexThreadBySessionId).
|
||||
expect(session.claudeSessionId).toBe(session.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Reading codex's own rollout store for Past Sessions rows.
|
||||
*
|
||||
* Three of these assertions exist because the obvious implementation was
|
||||
* measured to be wrong against real files (codex CLI 0.152.1):
|
||||
*
|
||||
* - 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`
|
||||
* instead, so a scanner that knew only the older shape found a prompt for
|
||||
* July rollouts and nothing for September ones.
|
||||
* - the `response_item` fallback sees codex's injected context, and on a real
|
||||
* store the FIRST such row is the repository's AGENTS.md every time. Taking
|
||||
* it literally titled every row with the same instructions block.
|
||||
* - codex spawns sub-agent threads into the same store, stamped
|
||||
* `thread_source: 'subagent'`. On the store this was built against they
|
||||
* outnumbered the threads a person can actually resume.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import { appendFile, mkdtemp, mkdir, writeFile, rm, utimes } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
import {
|
||||
scanCodexSessionsHistory,
|
||||
codexThreadBySessionId,
|
||||
__clearCodexIdentityCache,
|
||||
} from '../src/codex-transcript.js';
|
||||
|
||||
let home: string;
|
||||
let prevCodexHome: string | undefined;
|
||||
|
||||
/** A rollout's opening line, as codex writes it. */
|
||||
const sessionMeta = (opts: { id: string; cwd?: string; threadSource?: string; originator?: string }) =>
|
||||
JSON.stringify({
|
||||
timestamp: '2026-09-02T07:05:37.421Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: opts.id,
|
||||
session_id: opts.id,
|
||||
...(opts.cwd ? { cwd: opts.cwd } : {}),
|
||||
originator: opts.originator ?? 'codex-tui',
|
||||
...(opts.threadSource ? { thread_source: opts.threadSource } : {}),
|
||||
// The real thing embeds full base instructions here; padded so the file
|
||||
// clears the size floor and exercises the head window.
|
||||
base_instructions: { text: 'x'.repeat(500) },
|
||||
},
|
||||
});
|
||||
|
||||
/** codex 0.152.1's user-input row. */
|
||||
const itemCompletedUser = (text: string) =>
|
||||
JSON.stringify({
|
||||
type: 'event_msg',
|
||||
payload: { type: 'item_completed', item: { type: 'UserMessage', id: 'i1', content: [{ type: 'text', text }] } },
|
||||
});
|
||||
|
||||
/** The shape older codex versions wrote. */
|
||||
const legacyUserMessage = (text: string) =>
|
||||
JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: text } });
|
||||
|
||||
/** The last-resort shape, which also carries codex's injected context. */
|
||||
const responseItemUser = (text: string) =>
|
||||
JSON.stringify({ type: 'response_item', payload: { role: 'user', content: [{ type: 'text', text }] } });
|
||||
|
||||
async function writeRollout(id: string, lines: string[], mtime?: Date): Promise<string> {
|
||||
const dir = join(home, 'sessions', '2026', '09', '02');
|
||||
await mkdir(dir, { recursive: true });
|
||||
const path = join(dir, `rollout-2026-09-02T09-05-37-${id}.jsonl`);
|
||||
await writeFile(path, lines.join('\n') + '\n', 'utf-8');
|
||||
if (mtime) await utimes(path, mtime, mtime);
|
||||
return path;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), 'codex-transcript-'));
|
||||
prevCodexHome = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = home;
|
||||
__clearCodexIdentityCache();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prevCodexHome;
|
||||
await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('scanCodexSessionsHistory', () => {
|
||||
it('returns nothing when the store does not exist', async () => {
|
||||
process.env.CODEX_HOME = join(home, 'nope');
|
||||
expect(await scanCodexSessionsHistory()).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads the thread id, working directory and opening prompt', async () => {
|
||||
await writeRollout('01a060f0-0361-7f91-abde-b283020db0d7', [
|
||||
sessionMeta({ id: '01a060f0-0361-7f91-abde-b283020db0d7', cwd: '/repo/one' }),
|
||||
itemCompletedUser('Continue the audit log architecture'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].sessionId).toBe('01a060f0-0361-7f91-abde-b283020db0d7');
|
||||
expect(rows[0].workingDir).toBe('/repo/one');
|
||||
expect(rows[0].firstPrompt).toBe('Continue the audit log architecture');
|
||||
expect(rows[0].sizeBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('still reads the prompt shape older codex versions wrote', async () => {
|
||||
await writeRollout('11111111-1111-7111-8111-111111111111', [
|
||||
sessionMeta({ id: '11111111-1111-7111-8111-111111111111', cwd: '/repo/two' }),
|
||||
legacyUserMessage('$pr-review-comment-fixer 349'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].firstPrompt).toBe('$pr-review-comment-fixer 349');
|
||||
});
|
||||
|
||||
it('skips injected context when only the fallback shape is present', async () => {
|
||||
await writeRollout('22222222-2222-7222-8222-222222222222', [
|
||||
sessionMeta({ id: '22222222-2222-7222-8222-222222222222', cwd: '/repo/three' }),
|
||||
responseItemUser('# AGENTS.md instructions for /repo/three\n<INSTRUCTIONS> ...'),
|
||||
responseItemUser('<environment_context>cwd=/repo/three</environment_context>'),
|
||||
responseItemUser('Replace PanicOnError with Require().NoError'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].firstPrompt).toBe('Replace PanicOnError with Require().NoError');
|
||||
});
|
||||
|
||||
it('prefers a real user row over the injection-prone fallback', async () => {
|
||||
await writeRollout('33333333-3333-7333-8333-333333333333', [
|
||||
sessionMeta({ id: '33333333-3333-7333-8333-333333333333', cwd: '/repo/four' }),
|
||||
responseItemUser('Some earlier response_item row'),
|
||||
itemCompletedUser('The prompt the user actually typed'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].firstPrompt).toBe('The prompt the user actually typed');
|
||||
});
|
||||
|
||||
it('leaves out sub-agent threads, which nobody resumes', async () => {
|
||||
await writeRollout('44444444-4444-7444-8444-444444444444', [
|
||||
sessionMeta({ id: '44444444-4444-7444-8444-444444444444', cwd: '/repo/five' }),
|
||||
itemCompletedUser('a real conversation'),
|
||||
]);
|
||||
await writeRollout('55555555-5555-7555-8555-555555555555', [
|
||||
sessionMeta({ id: '55555555-5555-7555-8555-555555555555', cwd: '/repo/five', threadSource: 'subagent' }),
|
||||
itemCompletedUser('work codex gave itself'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows.map((r) => r.sessionId)).toEqual(['44444444-4444-7444-8444-444444444444']);
|
||||
});
|
||||
|
||||
it('reports the most recent prompt as well as the first', async () => {
|
||||
await writeRollout('66666666-6666-7666-8666-666666666666', [
|
||||
sessionMeta({ id: '66666666-6666-7666-8666-666666666666', cwd: '/repo/six' }),
|
||||
itemCompletedUser('the opening question'),
|
||||
itemCompletedUser('a follow-up'),
|
||||
itemCompletedUser('the latest thing asked'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].firstPrompt).toBe('the opening question');
|
||||
expect(rows[0].lastPrompt).toBe('the latest thing asked');
|
||||
});
|
||||
|
||||
it('orders rows newest first', async () => {
|
||||
await writeRollout(
|
||||
'77777777-7777-7777-8777-777777777777',
|
||||
[sessionMeta({ id: '77777777-7777-7777-8777-777777777777', cwd: '/repo/old' }), itemCompletedUser('older')],
|
||||
new Date('2026-08-01T00:00:00Z')
|
||||
);
|
||||
await writeRollout(
|
||||
'88888888-8888-7888-8888-888888888888',
|
||||
[sessionMeta({ id: '88888888-8888-7888-8888-888888888888', cwd: '/repo/new' }), itemCompletedUser('newer')],
|
||||
new Date('2026-09-05T00:00:00Z')
|
||||
);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows.map((r) => r.workingDir)).toEqual(['/repo/new', '/repo/old']);
|
||||
});
|
||||
|
||||
it('ignores a file too short to hold a session_meta line', async () => {
|
||||
const dir = join(home, 'sessions', '2026', '09', '02');
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(join(dir, 'rollout-2026-09-02T09-05-37-short.jsonl'), '{}\n', 'utf-8');
|
||||
|
||||
expect(await scanCodexSessionsHistory()).toEqual([]);
|
||||
});
|
||||
|
||||
it('survives a rollout whose lines are malformed', async () => {
|
||||
await writeRollout('99999999-9999-7999-8999-999999999999', [
|
||||
sessionMeta({ id: '99999999-9999-7999-8999-999999999999', cwd: '/repo/seven' }),
|
||||
'{not json at all',
|
||||
itemCompletedUser('still found me'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].firstPrompt).toBe('still found me');
|
||||
});
|
||||
|
||||
it('reports the originator, which is how a fresh pane finds its own rollout', async () => {
|
||||
await writeRollout('bbbbbbbb-bbbb-7bbb-8bbb-bbbbbbbbbbbb', [
|
||||
sessionMeta({
|
||||
id: 'bbbbbbbb-bbbb-7bbb-8bbb-bbbbbbbbbbbb',
|
||||
cwd: '/repo/nine',
|
||||
originator: 'codeman_2f1c9a44-1111-2222-3333-444455556666',
|
||||
}),
|
||||
itemCompletedUser('hello'),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].originator).toBe('codeman_2f1c9a44-1111-2222-3333-444455556666');
|
||||
});
|
||||
|
||||
it('drops a rollout that records no working directory', async () => {
|
||||
// Emitting workingDir: '' would make a click post an empty directory.
|
||||
await writeRollout('cccccccc-cccc-7ccc-8ccc-cccccccccccc', [
|
||||
sessionMeta({ id: 'cccccccc-cccc-7ccc-8ccc-cccccccccccc' }),
|
||||
itemCompletedUser('nowhere to resume into'),
|
||||
]);
|
||||
|
||||
expect(await scanCodexSessionsHistory()).toEqual([]);
|
||||
});
|
||||
|
||||
it('picks up a prompt written after an earlier scan saw none', async () => {
|
||||
// The bug this pins: the identity cache was written as soon as the thread id
|
||||
// was known, but codex writes the first UserMessage only when the user
|
||||
// submits. Any scan in that window — the home screen, the command palette,
|
||||
// the search-index refresh — pinned `firstPrompt: undefined` until restart.
|
||||
const id = 'dddddddd-dddd-7ddd-8ddd-dddddddddddd';
|
||||
const path = await writeRollout(id, [sessionMeta({ id, cwd: '/repo/ten' })]);
|
||||
|
||||
const before = await scanCodexSessionsHistory();
|
||||
expect(before).toHaveLength(1);
|
||||
expect(before[0].firstPrompt).toBeUndefined();
|
||||
|
||||
await appendFile(path, itemCompletedUser('the prompt, typed a moment later') + '\n', 'utf-8');
|
||||
|
||||
const after = await scanCodexSessionsHistory();
|
||||
expect(after[0].firstPrompt).toBe('the prompt, typed a moment later');
|
||||
});
|
||||
|
||||
it('does not spend the lastPrompt budget on rollouts it never returns', async () => {
|
||||
// The budget used to count file index, so a store whose newest files are all
|
||||
// sub-agent threads exhausted it before the first row that needed it.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await writeRollout(
|
||||
`eeeeeeee-eeee-7eee-8eee-00000000000${i}`,
|
||||
[
|
||||
sessionMeta({ id: `eeeeeeee-eeee-7eee-8eee-00000000000${i}`, cwd: '/repo/sub', threadSource: 'subagent' }),
|
||||
itemCompletedUser('subagent work'),
|
||||
],
|
||||
new Date('2026-09-05T00:00:00Z')
|
||||
);
|
||||
}
|
||||
await writeRollout(
|
||||
'ffffffff-ffff-7fff-8fff-ffffffffffff',
|
||||
[
|
||||
sessionMeta({ id: 'ffffffff-ffff-7fff-8fff-ffffffffffff', cwd: '/repo/real' }),
|
||||
itemCompletedUser('opening'),
|
||||
itemCompletedUser('the latest thing asked'),
|
||||
],
|
||||
new Date('2026-09-04T00:00:00Z')
|
||||
);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].lastPrompt).toBe('the latest thing asked');
|
||||
});
|
||||
|
||||
it('collapses a long prompt to a single capped line', async () => {
|
||||
await writeRollout('aaaaaaaa-aaaa-7aaa-8aaa-aaaaaaaaaaaa', [
|
||||
sessionMeta({ id: 'aaaaaaaa-aaaa-7aaa-8aaa-aaaaaaaaaaaa', cwd: '/repo/eight' }),
|
||||
itemCompletedUser('line one\nline two\n' + 'y'.repeat(500)),
|
||||
]);
|
||||
|
||||
const rows = await scanCodexSessionsHistory();
|
||||
expect(rows[0].firstPrompt).not.toContain('\n');
|
||||
expect(rows[0].firstPrompt!.length).toBeLessThanOrEqual(201);
|
||||
expect(rows[0].firstPrompt!.endsWith('…')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('codexThreadBySessionId', () => {
|
||||
const row = (sessionId: string, originator?: string) =>
|
||||
({ sessionId, originator, workingDir: '/w', sizeBytes: 1, lastModified: '2026-09-02T00:00:00.000Z' }) as never;
|
||||
|
||||
it('maps a Codeman-spawned pane to the thread it is writing', () => {
|
||||
const map = codexThreadBySessionId([row('thread-a', 'codeman_sess-1')]);
|
||||
expect(map.get('sess-1')).toBe('thread-a');
|
||||
});
|
||||
|
||||
it('ignores a rollout codex started on its own', () => {
|
||||
expect(codexThreadBySessionId([row('thread-a', 'codex-tui')]).size).toBe(0);
|
||||
expect(codexThreadBySessionId([row('thread-a', undefined)]).size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps the newest rollout when a pane has several', () => {
|
||||
// `/new` inside the codex TUI leaves the pane's originator on more than one
|
||||
// rollout; the pane is on the most recent, and rows arrive newest-first.
|
||||
const map = codexThreadBySessionId([row('thread-new', 'codeman_sess-1'), row('thread-old', 'codeman_sess-1')]);
|
||||
expect(map.get('sess-1')).toBe('thread-new');
|
||||
});
|
||||
});
|
||||
@@ -397,11 +397,13 @@ describe('Session Manager unified list', () => {
|
||||
expect(app.selectSession).toHaveBeenCalledWith('sess-alpha');
|
||||
expect(app.resumeHistorySession).not.toHaveBeenCalled();
|
||||
|
||||
// History row → resume by conversation UUID.
|
||||
// History row → resume by conversation UUID. The trailing `resumeId` is the
|
||||
// CLI's own thread token, which only a non-claude transcript scanner sets;
|
||||
// a Claude row carries none, so it arrives undefined here.
|
||||
const [historyRecord, , historyOptions] = app._buildHistoryItem.mock.calls[1];
|
||||
expect(historyRecord).toMatchObject({ sessionId: 'conv-uuid-1', sizeBytes: 2048, firstPrompt: 'old prompt' });
|
||||
historyOptions.onActivate();
|
||||
expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old', undefined, undefined);
|
||||
expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old', undefined, undefined, undefined);
|
||||
});
|
||||
|
||||
it('surfaces an error message instead of an empty list when the endpoint fails', async () => {
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('resumeHistorySession: row retirement is gated on actual continuation',
|
||||
fetchMock = stubFetch('new-session-id');
|
||||
});
|
||||
|
||||
it.each(['codex', 'gemini', 'antigravity'])(
|
||||
it.each(['gemini', 'antigravity'])(
|
||||
'does NOT retire the old row for %s (no continuation is wired for it)',
|
||||
async (mode) => {
|
||||
const app = makeApp();
|
||||
@@ -104,6 +104,44 @@ describe('resumeHistorySession: row retirement is gated on actual continuation',
|
||||
}
|
||||
);
|
||||
|
||||
// codex continues only when the row carried its thread id. A row without one
|
||||
// is a live session's row, whose sessionId is Codeman's own uuid — sending
|
||||
// THAT to `codex resume` asks for a thread that does not exist, so it must
|
||||
// stay a fresh session and must not retire the row it came from.
|
||||
it('does NOT continue or retire a codex row that carries no resumeId', async () => {
|
||||
const app = makeApp();
|
||||
await app.resumeHistorySession.call(app, 'codeman-uuid', '/repo', 'w1-repo', 'codex');
|
||||
|
||||
expect(createBody(fetchMock)).toMatchObject({ mode: 'codex' });
|
||||
expect(createBody(fetchMock).codexConfig).toBeUndefined();
|
||||
expect(deleteCalls(fetchMock)).toEqual([]);
|
||||
});
|
||||
|
||||
it('resumes a codex row by the thread id the row carried', async () => {
|
||||
const app = makeApp();
|
||||
await app.resumeHistorySession.call(
|
||||
app,
|
||||
'01a060f0-0361-7f91-abde-b283020db0d7',
|
||||
'/repo',
|
||||
'w1-repo',
|
||||
'codex',
|
||||
'01a060f0-0361-7f91-abde-b283020db0d7'
|
||||
);
|
||||
|
||||
expect(createBody(fetchMock)).toMatchObject({
|
||||
mode: 'codex',
|
||||
codexConfig: { resumeSessionId: '01a060f0-0361-7f91-abde-b283020db0d7' },
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a resumeId on a row that is not codex', async () => {
|
||||
const app = makeApp();
|
||||
await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', 'gemini', 'some-thread-id');
|
||||
|
||||
expect(createBody(fetchMock).codexConfig).toBeUndefined();
|
||||
expect(deleteCalls(fetchMock)).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['opencode', 'openCodeConfig'],
|
||||
['pi', 'piConfig'],
|
||||
|
||||
@@ -14,6 +14,94 @@ import {
|
||||
} from '../../src/services/unified-session-service.js';
|
||||
|
||||
describe('mergeUnifiedSessions', () => {
|
||||
// A codex conversation showing twice is worse than cosmetic: the stale PAST row
|
||||
// still resumes, so clicking it starts a SECOND `codex resume` on a thread
|
||||
// already open in another pane. Both folds below are what prevent that.
|
||||
it('folds a RESUMED codex session into its own rollout row', () => {
|
||||
// Session sets claudeSessionId from codexConfig.resumeSessionId, so the live
|
||||
// row already names the thread the rollout is keyed by.
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex', claudeSessionId: 'codex-thread-id' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'codex-thread-id',
|
||||
workingDir: '/w',
|
||||
sizeBytes: 4000,
|
||||
lastModified: '2026-09-02T00:00:00.000Z',
|
||||
mode: 'codex',
|
||||
resumeId: 'codex-thread-id',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].sessionId).toBe('codeman-uuid');
|
||||
expect([...merged[0].sources].sort()).toEqual(['history', 'live']);
|
||||
});
|
||||
|
||||
it('folds a FRESH codex session once its rollout has been matched by originator', () => {
|
||||
// A fresh pane knows no thread id, so gatherUnifiedInputs() stamps one onto
|
||||
// the live row from session_meta.originator (see codexThreadBySessionId).
|
||||
// This is that stamped row.
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-uuid', status: 'busy', mode: 'codex', claudeSessionId: 'fresh-thread-id' }],
|
||||
persisted: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex', claudeSessionId: 'fresh-thread-id' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'fresh-thread-id',
|
||||
workingDir: '/w',
|
||||
sizeBytes: 900,
|
||||
lastModified: '2026-09-02T00:00:00.000Z',
|
||||
mode: 'codex',
|
||||
resumeId: 'fresh-thread-id',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0].sessionId).toBe('codeman-uuid');
|
||||
expect(merged[0].status).toBe('busy');
|
||||
});
|
||||
|
||||
it('leaves an unrelated codex rollout as its own row', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex', claudeSessionId: 'thread-one' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'thread-two',
|
||||
workingDir: '/w',
|
||||
sizeBytes: 4000,
|
||||
lastModified: '2026-09-02T00:00:00.000Z',
|
||||
mode: 'codex',
|
||||
resumeId: 'thread-two',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(merged.map((m) => m.sessionId).sort()).toEqual(['codeman-uuid', 'thread-two']);
|
||||
});
|
||||
|
||||
it("carries a transcript row's own resume token, and stamps none on a live row", () => {
|
||||
// codex names a thread by an id in its rollout, not by Codeman's session id.
|
||||
// The scanner sets `resumeId`; a live session never does, which is what stops
|
||||
// a resume from asking codex for a thread whose id is really Codeman's uuid.
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'codex-thread-id',
|
||||
workingDir: '/w',
|
||||
sizeBytes: 4000,
|
||||
lastModified: '2026-09-02T00:00:00.000Z',
|
||||
mode: 'codex',
|
||||
resumeId: 'codex-thread-id',
|
||||
},
|
||||
],
|
||||
});
|
||||
const fromTranscript = merged.find((m) => m.sessionId === 'codex-thread-id');
|
||||
const fromLive = merged.find((m) => m.sessionId === 'codeman-uuid');
|
||||
expect(fromTranscript?.resumeId).toBe('codex-thread-id');
|
||||
expect(fromTranscript?.mode).toBe('codex');
|
||||
expect(fromLive?.resumeId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('dedupes the same sessionId across live + persisted into one item', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 's1', status: 'working', isWorking: true }],
|
||||
|
||||
@@ -100,9 +100,11 @@ describe('Session claude conversation chain', () => {
|
||||
// before this existed. Every assignment built from the launch-id fallback
|
||||
// must therefore carry `restoredConversation` first.
|
||||
const source = readFileSync(resolve(import.meta.dirname, '../src/session.ts'), 'utf8');
|
||||
const fallbackAssignments = source.match(
|
||||
/_claudeSessionId =\s*\n?\s*[^;]*?_resumeSessionId \|\| this\._ompConfig\?\.resumeSessionId \|\| this\.id;/g
|
||||
);
|
||||
// The tail of the chain grows as each CLI gains a resume alias of its own
|
||||
// (omp, then codex), so the pattern pins the two ends and lets the middle
|
||||
// widen. A `[^;]` run cannot cross a statement boundary, so each match is
|
||||
// still one assignment.
|
||||
const fallbackAssignments = source.match(/_claudeSessionId =[^;]*?_resumeSessionId[^;]*?this\.id;/g);
|
||||
expect(fallbackAssignments).not.toBeNull();
|
||||
expect(fallbackAssignments!.length).toBeGreaterThanOrEqual(2);
|
||||
for (const assignment of fallbackAssignments!) {
|
||||
|
||||
Reference in New Issue
Block a user