mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
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/<mangled-cwd>/<uuid>.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).
This commit is contained in:
@@ -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<typeof statSync>;
|
||||
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<string, unknown>;
|
||||
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<typeof statSync>;
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+80
-7
@@ -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
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @fileoverview Resolve the real OMP session id for a working directory, so a
|
||||
* relaunch can pass `--resume <id>` instead of the ambiguous `--continue`.
|
||||
*
|
||||
* `omp` persists each conversation as its own file under
|
||||
* `~/.omp/agent/sessions/<mangled-workingDir>/<ISO-timestamp>_<session-uuid>.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 `<ISO-ish-timestamp>_<uuid>.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;
|
||||
}
|
||||
@@ -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<void> {
|
||||
// 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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user