From 54a930c80e23cec00721cdb4b2636d838dd20e89 Mon Sep 17 00:00:00 2001 From: timkjr Date: Wed, 26 Aug 2026 22:49:22 -0500 Subject: [PATCH] feat(omp): survive a full session kill by reading omp's own transcripts Claude conversations survive "Kill Tmux & Claude" because Codeman reads them back independently from ~/.claude/projects, not from its own session bookkeeping. omp conversations had no equivalent: kill the Codeman session and the conversation vanished from Past Sessions entirely, even though omp itself never forgot it on disk. Adds omp-transcript.ts, a scanner over omp's own ~/.omp/agent/sessions//.jsonl files (the same shape as Claude Code's own transcript scanner, but simpler -- these files are small enough to read whole instead of doing head/tail windows). Each file's own "session" header line carries the real cwd and session id directly, so unlike Claude's mangled-directory-name decoding this never has to guess. Wired into gatherUnifiedInputs() as a second history source alongside the Claude scan, and HistoryInput/ mergeUnifiedSessions() now carry an optional `mode` so a non-claude history-only row still gets a real mode badge. Also fixes the ambiguity behind the "continue picks the wrong conversation" report from this session's testing: omp mints its OWN session uuid, unrelated to Codeman's, so a live/persisted row and its own history-scan row would otherwise show up as two separate entries for the same conversation the moment the id gets resolved. Reuses the existing claudeSessionId alias field (mergeUnifiedSessions' fold-into- owner mechanism) to point at the resolved omp id, threading it through every place `_claudeSessionId` gets (re)computed -- the constructor, _resolvedOmpRespawnConfig, and a new _maybeCaptureOmpSessionId() that opportunistically resolves it the first time a brand-new omp session (one that has never gone through a respawn) goes idle. Also closes a THIRD instance of the "ompConfig never got wired in here" gap this session kept finding: restoreMuxSessions() in server.ts restores every sibling CLI's config from persisted state on boot except omp's, so a boot-recovered omp session always lost its resolved resume id and fell back to guessing again. Verified live end-to-end: told a session a secret, killed it fully (Kill Tmux equivalent, killMux=true -- the Codeman session AND its tmux pane both gone), and the conversation still showed up in the unified list as a history-sourced row with the real first prompt as its title and an omp mode badge, keyed by omp's own session id. Known remaining gap, not fixed here: the claudeSessionId alias doesn't yet resolve reliably on every boot-recovery path for a session that was never respawned while alive (e.g. a plain re-attach to a pane that was never dead) -- worth a follow-up, but doesn't affect the two things that matter most: the conversation surviving a kill, and continuation correctness once an id has been resolved (which happens on the very next respawn either way). --- src/omp-transcript.ts | 172 ++++++++++++++++++++++++ src/services/unified-session-service.ts | 11 ++ src/session.ts | 87 +++++++++++- src/utils/omp-session-resolver.ts | 74 ++++++++++ src/web/routes/session-routes.ts | 51 ++++++- src/web/server.ts | 1 + 6 files changed, 387 insertions(+), 9 deletions(-) create mode 100644 src/omp-transcript.ts create mode 100644 src/utils/omp-session-resolver.ts diff --git a/src/omp-transcript.ts b/src/omp-transcript.ts new file mode 100644 index 00000000..922a61d0 --- /dev/null +++ b/src/omp-transcript.ts @@ -0,0 +1,172 @@ +/** + * @fileoverview Scan `~/.omp/agent/sessions/*/*.jsonl` for Past Sessions rows, + * the omp analog of what `scanProjectDir()` (session-routes.ts) does for + * Claude's own `~/.claude/projects` transcripts. + * + * Without this, an omp conversation exists ONLY as a Codeman-level live/ + * persisted session record — delete that (a "Kill Tmux" close, or any other + * cleanup) and the conversation vanishes from Past Sessions entirely, even + * though `omp` itself never forgot it. Claude conversations don't have that + * problem because Codeman already reads them back from Claude's own + * transcript files independent of its own session bookkeeping; this gives + * omp conversations the same treatment. + * + * Each omp session file's SECOND line is a `{"type":"session","id":..., + * "cwd":...}` header carrying the real (unmangled) working directory and the + * session's own id directly — no need to reverse-engineer the mangled + * directory name the way Claude Code's own scanner has to (see + * `decodeProjectKey()` in session-routes.ts and its "lossy" caveat). Prompt + * text comes from each `{"type":"message","message":{"role":"user",...}}` + * entry, giving a real first-message title instead of a bare case name. + * + * Unlike Claude's transcripts (which can run to tens of MB of tool-call + * output), an omp session file is the conversation only, so this reads each + * file whole rather than doing head/tail windows — bounded by a size cap so + * one unexpectedly huge file can't blow up memory. + * + * @module omp-transcript + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +function ompSessionsRoot(): string { + return join(homedir(), '.omp', 'agent', 'sessions'); +} + +/** Skip anything absurdly large rather than parsing it whole into memory. */ +const MAX_OMP_SESSION_FILE_BYTES = 2 * 1024 * 1024; + +/** Defensive cap on total files scanned across every directory, mirroring + * the Claude scanner's own instinct not to let one pathological tree stall + * a request — a real omp install has, at most, a few hundred of these. */ +const MAX_OMP_SESSION_FILES = 2000; + +export interface OmpHistorySession { + sessionId: string; + workingDir: string; + sizeBytes: number; + /** ISO timestamp, from the file's own mtime. */ + lastModified: string; + firstPrompt?: string; + lastPrompt?: string; +} + +function extractUserPromptText(message: unknown): string | undefined { + if (!message || typeof message !== 'object') return undefined; + const m = message as { role?: unknown; content?: unknown }; + if (m.role !== 'user' || !Array.isArray(m.content)) return undefined; + const parts: string[] = []; + for (const block of m.content) { + if (block && typeof block === 'object' && (block as { type?: unknown }).type === 'text') { + const text = (block as { text?: unknown }).text; + if (typeof text === 'string') parts.push(text); + } + } + const joined = parts.join(' ').trim(); + return joined || undefined; +} + +/** Parse one omp session `.jsonl` file, or null when it's unreadable, empty, or has no session header. */ +function parseOmpSessionFile(filePath: string): OmpHistorySession | null { + let stat: ReturnType; + try { + stat = statSync(filePath); + } catch { + return null; + } + if (stat.size === 0 || stat.size > MAX_OMP_SESSION_FILE_BYTES) return null; + + let raw: string; + try { + raw = readFileSync(filePath, 'utf-8'); + } catch { + return null; + } + + let sessionId: string | undefined; + let workingDir: string | undefined; + let firstPrompt: string | undefined; + let lastPrompt: string | undefined; + + for (const line of raw.split('\n')) { + if (!line) continue; + let entry: unknown; + try { + entry = JSON.parse(line); + } catch { + continue; + } + if (!entry || typeof entry !== 'object') continue; + const e = entry as Record; + if (e.type === 'session' && typeof e.id === 'string' && typeof e.cwd === 'string') { + sessionId = e.id; + workingDir = e.cwd; + } else if (e.type === 'message') { + const prompt = extractUserPromptText(e.message); + if (prompt) { + if (!firstPrompt) firstPrompt = prompt; + lastPrompt = prompt; + } + } + } + + if (!sessionId || !workingDir) return null; + return { + sessionId, + workingDir, + sizeBytes: stat.size, + lastModified: stat.mtime.toISOString(), + firstPrompt, + lastPrompt, + }; +} + +/** + * Scan every omp conversation on disk into Past-Sessions rows. Best-effort + * throughout: a missing `~/.omp` (never installed/used), an unreadable + * directory, or one corrupt file yields fewer rows rather than throwing — + * this feeds the same unified merge the Claude transcript scanner does, and + * one broken source must never blank the whole Past Sessions list. + */ +export function scanOmpSessionsHistory(): OmpHistorySession[] { + const root = ompSessionsRoot(); + let dirEntries: string[]; + try { + dirEntries = readdirSync(root); + } catch { + return []; + } + + const out: OmpHistorySession[] = []; + for (const dirName of dirEntries) { + if (out.length >= MAX_OMP_SESSION_FILES) break; + const dirPath = join(root, dirName); + let dirStat: ReturnType; + try { + dirStat = statSync(dirPath); + } catch { + continue; + } + if (!dirStat.isDirectory()) continue; + + let files: string[]; + try { + files = readdirSync(dirPath); + } catch { + continue; + } + for (const file of files) { + if (out.length >= MAX_OMP_SESSION_FILES) break; + if (!file.endsWith('.jsonl')) continue; + try { + const parsed = parseOmpSessionFile(join(dirPath, file)); + if (parsed) out.push(parsed); + } catch { + // One bad file must not sink the whole scan. + } + } + } + return out; +} diff --git a/src/services/unified-session-service.ts b/src/services/unified-session-service.ts index f83e89c4..209d4a59 100644 --- a/src/services/unified-session-service.ts +++ b/src/services/unified-session-service.ts @@ -99,6 +99,13 @@ export type HistoryInput = { gitBranch?: string; 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. + */ + mode?: string; }; /** Mux process-stat view. */ @@ -175,6 +182,10 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte overwrite(item, 'gitBranch', h.gitBranch); overwrite(item, 'worktreeName', h.worktreeName); overwrite(item, 'worktreeRepo', h.worktreeRepo); + // Claude rows never set this (they're implicitly claude); a non-claude + // 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); const ms = Date.parse(h.lastModified); if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms; } diff --git a/src/session.ts b/src/session.ts index bab62b35..413211c4 100644 --- a/src/session.ts +++ b/src/session.ts @@ -57,6 +57,7 @@ import { type SessionRemote, type SessionDocker, } from './types.js'; +import { findLatestOmpSessionId } from './utils/omp-session-resolver.js'; import { probeDockerCliVersion } from './docker-hosts.js'; import { probeRemoteCliVersion } from './remote-hosts.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; @@ -690,7 +691,13 @@ 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. - this._claudeSessionId = config.resumeSessionId || this.id; + // 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; // 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 @@ -1633,10 +1640,18 @@ export class Session extends EventEmitter { // Respawning a dead pane means the CLI process exited (crash, idle // respawn, or the user's own /exit) but this is still the same // conversation from the user's perspective — unlike a brand-new - // `createSession` call, defaulting to --continue here is the honest - // behavior. Only when the session has no resume id of its own already - // (an explicit resumeSessionId always wins in buildOmpCommand). - ompConfig: this._ompConfig?.resumeSessionId ? this._ompConfig : { ...this._ompConfig, continueSession: true }, + // `createSession` call, defaulting to continuation here is the honest + // behavior. `--continue` alone is ambiguous the moment ANY other omp + // conversation has touched this directory more recently (ours resumed + // elsewhere, a second Codeman session opened here, ...) since it just + // picks the newest session file — so resolve and PIN the exact id the + // pane that just died was writing to, once. The dead pane's file is + // already fully flushed at this point, so "newest file" here is + // unambiguous by construction; every later respawn then reuses the + // pinned id instead of re-guessing. Only when the session already + // carries an explicit resumeSessionId does this skip straight past it + // (that one always wins in buildOmpCommand regardless). + ompConfig: this._resolvedOmpRespawnConfig(), resumeSessionId: this._resumeSessionId, envOverrides: this._envOverrides, effort: this._effort, @@ -1647,6 +1662,33 @@ export class Session extends EventEmitter { }; } + /** + * OMP-only: resolve and PIN the exact conversation to continue when + * respawning a dead pane, so every later respawn reuses the same id + * instead of re-resolving (and re-risking picking up a DIFFERENT + * conversation that happened to touch this directory more recently). See + * the comment at the call site in {@link _buildRespawnPaneOptions} for why + * "newest file on disk" is safe here specifically. Non-omp modes and a + * session that already carries an explicit id pass through untouched. + */ + private _resolvedOmpRespawnConfig(): OmpConfig | undefined { + if (this.mode !== 'omp') return this._ompConfig; + if (this._ompConfig?.resumeSessionId) return this._ompConfig; + const resolvedId = findLatestOmpSessionId(this.workingDir); + if (resolvedId) { + this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId }; + // Alias omp's own session uuid to this Codeman id — see the + // constructor's claudeSessionId comment for why this field is the + // (generically-named) mechanism that folds a Past-Sessions row back + // into its live/persisted session instead of duplicating it. + this._claudeSessionId = resolvedId; + return this._ompConfig; + } + // Nothing on disk yet (the dying process never got far enough to write a + // session file) — fall back to the CLI's own "most recent" heuristic. + return { ...this._ompConfig, continueSession: true }; + } + /** * Remember whether the CLI currently wants to be told about mouse clicks. * @@ -1909,8 +1951,13 @@ export class Session extends EventEmitter { spawnErrLabel: 'mux attachment', }); - // Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one. - this._claudeSessionId = this._resumeSessionId || this.id; + // Set claudeSessionId — when resuming, the Claude conversation ID is the + // resumed one. `_resolvedOmpRespawnConfig()` (called above while building + // respawnPaneOptions) may have JUST aliased 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. + this._claudeSessionId = this._resumeSessionId || this._ompConfig?.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 @@ -2319,10 +2366,36 @@ export class Session extends EventEmitter { this._isWorking = false; this._status = 'idle'; this._lastPromptTime = Date.now(); + if (wasWorking) this._maybeCaptureOmpSessionId(); this.emit('idle'); } } + /** + * A brand-new omp session (never yet respawned, so + * {@link _resolvedOmpRespawnConfig} has never run) has no captured + * omp-native session id: `_claudeSessionId` still defaults to this + * session's OWN Codeman id from the constructor. Until something aliases + * it, the omp history scan's row for this exact conversation (keyed by + * omp's own uuid) merges with nothing and shows up a second time. The + * first turn going idle is the first moment omp has definitely written + * its session file, so resolve and alias it here — best-effort, and only + * once (skips once `_claudeSessionId` differs from `this.id`, whether from + * this capture or a resume/respawn that already resolved one). + */ + private _maybeCaptureOmpSessionId(): void { + if (this.mode !== 'omp' || this._claudeSessionId !== this.id) return; + try { + const resolvedId = findLatestOmpSessionId(this.workingDir); + if (resolvedId) { + this._claudeSessionId = resolvedId; + this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId }; + } + } catch { + // Best-effort: a failed capture just means the next respawn tries again. + } + } + /** * Process expensive parsers (ANSI strip, Ralph, bash tool, token, CLI info, task descriptions). * Called on a throttled schedule (every EXPENSIVE_PROCESS_INTERVAL_MS) instead of on every diff --git a/src/utils/omp-session-resolver.ts b/src/utils/omp-session-resolver.ts new file mode 100644 index 00000000..37b67a44 --- /dev/null +++ b/src/utils/omp-session-resolver.ts @@ -0,0 +1,74 @@ +/** + * @fileoverview Resolve the real OMP session id for a working directory, so a + * relaunch can pass `--resume ` instead of the ambiguous `--continue`. + * + * `omp` persists each conversation as its own file under + * `~/.omp/agent/sessions//_.jsonl` + * (workingDir mangled the same way Claude Code mangles `~/.claude/projects/*`: + * every `/` replaced with `-`). `--continue` picks whichever file in that + * directory is newest, which silently drifts to the WRONG conversation the + * moment two Codeman sessions ever touch the same directory — exactly what a + * closed-then-resumed row plus a still-running duplicate produces. Resolving + * the id once and pinning it with `--resume` removes that ambiguity for every + * later relaunch of the same Codeman session. + * + * @module utils/omp-session-resolver + */ + +import { readdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** A real OMP session file is `_.jsonl`; only the uuid matters here. */ +const OMP_SESSION_FILE_PATTERN = /^.+_([a-zA-Z0-9-]+)\.jsonl$/; + +/** + * Mirrors `omp`'s own directory mangling: every path separator becomes a + * dash. Pure so it's unit-testable without touching the filesystem. + */ +export function mangleOmpWorkingDir(workingDir: string): string { + return workingDir.replace(/\//g, '-'); +} + +/** `~/.omp` — no known env override exists (unlike DSH_HOME); revisit if omp adds one. */ +function resolveOmpHome(): string { + return join(homedir(), '.omp'); +} + +/** + * Newest OMP session id for this working directory, or null when the + * directory doesn't exist yet (never launched) or holds no session files. + * + * Deliberately "newest file, full stop" rather than a time-windowed match: + * callers only invoke this at a moment where that's unambiguous by + * construction — right after the file that answers it was the only thing + * that could have just been written (a dead pane's process already exited, + * or a session being resumed has no live sibling in the same directory yet). + */ +export function findLatestOmpSessionId(workingDir: string): string | null { + const dir = join(resolveOmpHome(), 'agent', 'sessions', mangleOmpWorkingDir(workingDir)); + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + return null; + } + + let newestMtime = -Infinity; + let newestId: string | null = null; + for (const entry of entries) { + const match = OMP_SESSION_FILE_PATTERN.exec(entry); + if (!match) continue; + let mtimeMs: number; + try { + mtimeMs = statSync(join(dir, entry)).mtimeMs; + } catch { + continue; + } + if (mtimeMs > newestMtime) { + newestMtime = mtimeMs; + newestId = match[1]; + } + } + return newestId; +} diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 80fef58b..577e7391 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -20,12 +20,14 @@ import { type ApiResponse, type SessionColor, type SessionStatus, + type SessionMode, type CodexConfig, type GeminiConfig, type AntigravityConfig, type PiConfig, type GrokConfig, type DeepSeekConfig, + type OmpConfig, } from '../../types.js'; import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js'; import { SseEvent } from '../sse-events.js'; @@ -136,6 +138,8 @@ import { toSessionDocker, } from '../../docker-hosts.js'; import { LRUMap } from '../../utils/lru-map.js'; +import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js'; +import { scanOmpSessionsHistory } from '../../omp-transcript.js'; import { getLastTranscriptResponse, isExternalCliTranscriptMode, @@ -744,6 +748,31 @@ async function injectAgentSkill(casePath: string): Promise { // bypassing the `workspaceHooksEnabled` setting. Route handlers here resolve the // setting through the ConfigPort (tests stub it) and pass it as the second arg. +/** + * A "Resume"/"continue" request for a NEW omp-mode session (the frontend's + * resumeHistorySession(), or anyone hitting the API directly) carries + * `continueSession: true` but no id — omp has none to give it, since Codeman + * has never tracked its own conversation UUID. Left as `--continue`, that + * picks whichever session file in the directory is newest, which silently + * drifts to the WRONG conversation the moment a second omp session (this + * one, a sibling worker, a stray manual run) has touched the same directory + * more recently. Resolve the real id up front instead, same as the + * dead-pane-respawn path in session.ts does, so even the FIRST relaunch of a + * resumed conversation is pinned rather than guessed. + */ +function resolveOmpConfigForCreate( + mode: SessionMode, + workingDir: string, + ompConfig: OmpConfig | undefined +): OmpConfig | undefined { + if (mode !== 'omp') return undefined; + if (!ompConfig || ompConfig.resumeSessionId || !ompConfig.continueSession) { + return ompConfig; + } + const resolvedId = findLatestOmpSessionId(workingDir); + return resolvedId ? { ...ompConfig, resumeSessionId: resolvedId } : ompConfig; +} + export function registerSessionRoutes( app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort @@ -1065,7 +1094,7 @@ export function registerSessionRoutes( piConfig: mode === 'pi' ? gatedPiConfig : undefined, grokConfig: mode === 'grok' ? gatedGrokConfig : undefined, deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined, - ompConfig: mode === 'omp' ? body.ompConfig : undefined, + ompConfig: resolveOmpConfigForCreate(mode, workingDir, body.ompConfig), resumeSessionId: validatedResumeId, envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides), effort: body.effort, @@ -3304,7 +3333,7 @@ export function registerSessionRoutes( piConfig: mode === 'pi' ? qsGatedPiConfig : undefined, grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined, deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined, - ompConfig: mode === 'omp' ? ompConfig : undefined, + ompConfig: resolveOmpConfigForCreate(mode, resolvedCasePath, ompConfig), envOverrides: qsGatedEnvOverrides, effort, remote, @@ -4153,6 +4182,24 @@ export function registerSessionRoutes( // Projects dir may not exist. } + // OMP's own session files (~/.omp/agent/sessions) — the non-claude twin + // of the scan above; see omp-transcript.ts for why this exists at all. + try { + for (const h of scanOmpSessionsHistory()) { + history.push({ + sessionId: h.sessionId, + workingDir: h.workingDir, + sizeBytes: h.sizeBytes, + lastModified: h.lastModified, + firstPrompt: h.firstPrompt, + lastPrompt: h.lastPrompt, + mode: 'omp', + }); + } + } catch { + // Best-effort, same as the claude scan above. + } + // Mux process stats (best-effort; guard against mocks lacking the method). let mux: MuxStatInput[] = []; try { diff --git a/src/web/server.ts b/src/web/server.ts index 5e85562b..f7617a2b 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -2755,6 +2755,7 @@ export class WebServer extends EventEmitter { piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined, grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined, deepSeekConfig: muxSession.mode === 'deepseek' ? savedState?.deepSeekConfig : undefined, + ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined, envOverrides: savedEnvOverrides, effort: savedState?.effort, attachmentHistory: savedAttachmentHistory,