mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
Merge pull request #353 from timkjr/omp-mode
feat: add OMP (Oh My Pi) as a new session backend
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
import { PI_VERSION_REGEX } from '../utils/pi-cli-resolver.js';
|
||||
import { GROK_VERSION_REGEX } from '../utils/grok-cli-resolver.js';
|
||||
import { DEEPSEEK_VERSION_REGEX } from '../utils/deepseek-cli-resolver.js';
|
||||
import { OMP_VERSION_REGEX } from '../utils/omp-cli-resolver.js';
|
||||
|
||||
export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl';
|
||||
|
||||
@@ -190,6 +191,30 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'omp',
|
||||
label: 'OMP CLI',
|
||||
category: 'core',
|
||||
required: false,
|
||||
usedBy: ['OMP sessions'],
|
||||
// Same version-match discipline as pi: `omp` is a short generic name, so a
|
||||
// `which omp` hit alone is not the coding agent. Both sides share
|
||||
// OMP_VERSION_REGEX, so the doctor and the run mode cannot drift into telling
|
||||
// the user opposite things about the same binary.
|
||||
resolvers: [
|
||||
{
|
||||
match: ALL,
|
||||
resolver: {
|
||||
kind: 'path',
|
||||
bins: ['omp'],
|
||||
versionArg: '--version',
|
||||
versionRegex: OMP_VERSION_REGEX,
|
||||
requireVersionMatch: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
installHint: { linux: 'curl -fsSL https://omp.sh/install | sh', darwin: 'brew install can1357/tap/omp' },
|
||||
},
|
||||
{
|
||||
id: 'libreoffice',
|
||||
label: 'LibreOffice',
|
||||
|
||||
@@ -147,6 +147,7 @@ export function defaultDockerCommandForMode(mode: SessionMode): string {
|
||||
pi: 'exec pi',
|
||||
grok: 'exec grok',
|
||||
deepseek: 'exec dsh',
|
||||
omp: 'exec omp',
|
||||
};
|
||||
return commands[mode as DockerCommandMode] || commands.shell;
|
||||
}
|
||||
@@ -639,6 +640,22 @@ const CRED_STORES: CredStorePolicy[] = [
|
||||
},
|
||||
{ rel: '.config/gcloud', seedWhole: true },
|
||||
{ rel: '.config/opencode', seedWhole: true },
|
||||
// OMP keeps its config in `~/.omp/agent` (config.yml/mcp.json/models.yml/
|
||||
// settings.yml — small, no bigger than grok's config.toml/pager.toml), but
|
||||
// that dir ALSO holds agent.db/history.db/models.db (SQLite caches) and
|
||||
// terminal-sessions/blobs/cache (large, regenerable), so seed only the
|
||||
// config files. UNLIKE pi/grok, `sessions/` is SHARED (RW), not
|
||||
// host-invisible: Codeman reads `~/.omp/agent/sessions/**/*.jsonl`
|
||||
// HOST-SIDE for history recovery and --resume pinning
|
||||
// (omp-transcript.ts, omp-session-resolver.ts) — the same reason codex's
|
||||
// `sessions/` is shared rather than seeded. Without this, an in-container
|
||||
// OMP conversation would be invisible to Codeman's own history-scan/resume
|
||||
// logic, silently breaking the kill-survival feature for Docker cases.
|
||||
{
|
||||
rel: '.omp/agent',
|
||||
shareDirs: ['sessions'],
|
||||
seedFiles: ['config.yml', 'mcp.json', 'models.yml', 'settings.yml'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
PiConfig,
|
||||
GrokConfig,
|
||||
DeepSeekConfig,
|
||||
OmpConfig,
|
||||
SessionRemote,
|
||||
SessionDocker,
|
||||
} from './types.js';
|
||||
@@ -82,6 +83,7 @@ export interface CreateSessionOptions {
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
|
||||
@@ -116,6 +118,7 @@ export interface RespawnPaneOptions {
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
/** Resume a previous Claude conversation when respawning */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported before launching the CLI (preserved across respawns). */
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @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' && e.cwd.startsWith('/')) {
|
||||
// A corrupted or malformed session file could carry a relative or empty
|
||||
// cwd; requiring an absolute path keeps a downstream resume attempt
|
||||
// from being pointed at a nonsense working directory.
|
||||
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;
|
||||
}
|
||||
@@ -119,6 +119,7 @@ export function defaultRemoteCommandForMode(mode: SessionMode): string {
|
||||
// profile inventory is unknown here. The per-host `commands.deepseek` override
|
||||
// is the escape hatch for naming one.
|
||||
deepseek: remoteLoginShellCommand('dsh'),
|
||||
omp: remoteLoginShellCommand('omp'),
|
||||
};
|
||||
return commands[mode as RemoteCommandMode] || commands.shell;
|
||||
}
|
||||
@@ -274,6 +275,7 @@ const REMOTE_CLI_BIN: Partial<Record<SessionMode, string>> = {
|
||||
gemini: 'gemini',
|
||||
antigravity: 'agy',
|
||||
pi: 'pi',
|
||||
omp: 'omp',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+120
-6
@@ -53,9 +53,11 @@ import {
|
||||
type PiConfig,
|
||||
type GrokConfig,
|
||||
type DeepSeekConfig,
|
||||
type OmpConfig,
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
} from './types.js';
|
||||
import { resolveAndClaimOmpSessionId } 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';
|
||||
@@ -180,7 +182,8 @@ export function isExternalCliMode(mode: SessionMode): boolean {
|
||||
mode === 'antigravity' ||
|
||||
mode === 'pi' ||
|
||||
mode === 'grok' ||
|
||||
mode === 'deepseek'
|
||||
mode === 'deepseek' ||
|
||||
mode === 'omp'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -200,6 +203,8 @@ function getModeLabel(mode: SessionMode): string {
|
||||
return 'Grok';
|
||||
case 'deepseek':
|
||||
return 'DeepSeek';
|
||||
case 'omp':
|
||||
return 'OMP';
|
||||
case 'shell':
|
||||
return 'Shell';
|
||||
case 'claude':
|
||||
@@ -528,6 +533,8 @@ export class Session extends EventEmitter {
|
||||
|
||||
// DeepSeek Harness configuration (only for mode === 'deepseek')
|
||||
private _deepSeekConfig: DeepSeekConfig | undefined;
|
||||
// OMP configuration (only for mode === 'omp')
|
||||
private _ompConfig: OmpConfig | undefined;
|
||||
private _resumeSessionId: string | undefined;
|
||||
|
||||
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
|
||||
@@ -627,6 +634,8 @@ export class Session extends EventEmitter {
|
||||
grokConfig?: GrokConfig;
|
||||
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
/** OMP configuration (only for mode === 'omp') */
|
||||
ompConfig?: OmpConfig;
|
||||
/** Resume a previous Claude conversation (used after server reboot) */
|
||||
resumeSessionId?: string;
|
||||
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
|
||||
@@ -682,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
|
||||
@@ -735,6 +750,10 @@ export class Session extends EventEmitter {
|
||||
if (config.piConfig) {
|
||||
this._piConfig = config.piConfig;
|
||||
}
|
||||
// Apply OMP configuration
|
||||
if (config.ompConfig) {
|
||||
this._ompConfig = config.ompConfig;
|
||||
}
|
||||
|
||||
// Apply DeepSeek Harness configuration
|
||||
if (config.deepSeekConfig) {
|
||||
@@ -1368,6 +1387,7 @@ export class Session extends EventEmitter {
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
ompConfig: this._ompConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
effort: this._effort,
|
||||
// COD-118: runtime-only — surfaced so the frontend can require explicit user
|
||||
@@ -1494,7 +1514,11 @@ export class Session extends EventEmitter {
|
||||
let needsNewSession = false;
|
||||
if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) {
|
||||
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
|
||||
const newPid = await mux.respawnPane(options.respawnPaneOptions);
|
||||
// Confirmed dead — safe to resolve/pin now (see `_pinOmpRespawnId()`).
|
||||
// `options.respawnPaneOptions` was built eagerly before this dead-pane
|
||||
// check ran, so it still carries the pre-pin ompConfig; rebuild it.
|
||||
this._pinOmpRespawnId();
|
||||
const newPid = await mux.respawnPane(this._buildRespawnPaneOptions());
|
||||
if (!newPid) {
|
||||
console.error('[Session] Failed to respawn pane, will create new session');
|
||||
needsNewSession = true;
|
||||
@@ -1585,6 +1609,9 @@ export class Session extends EventEmitter {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Confirmed the mux session (and thus the pane) exists but this reattach
|
||||
// is about to respawn it — safe to resolve/pin now.
|
||||
this._pinOmpRespawnId();
|
||||
const newPid = await mux.respawnPane(this._buildRespawnPaneOptions());
|
||||
if (!newPid) {
|
||||
console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName);
|
||||
@@ -1617,6 +1644,16 @@ export class Session extends EventEmitter {
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
// OMP resolution/pinning does NOT happen here. This object is built
|
||||
// EAGERLY — including on every boot-recovery reattach, before anyone
|
||||
// knows whether the pane is actually dead — so resolving here mutated
|
||||
// `_ompConfig`/`_claudeSessionId` even for a pane that was simply being
|
||||
// reattached to, not respawned; with two omp tabs in the same case dir
|
||||
// that mis-pinned the ALIVE session onto whichever file happened to be
|
||||
// newest on disk (reported live in the Ark0N/Codeman#353 review). The
|
||||
// real pin now happens in `_pinOmpRespawnId()`, called by callers ONLY
|
||||
// once they've confirmed an actual respawn is about to happen.
|
||||
ompConfig: this._ompConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
@@ -1627,6 +1664,45 @@ 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 _pinOmpRespawnId(): void {
|
||||
if (this.mode !== 'omp') return;
|
||||
if (this._ompConfig?.resumeSessionId) return;
|
||||
// Callers MUST call this only immediately before an ACTUAL respawn (a
|
||||
// confirmed-dead pane, or a genuine remote reattach) — never while merely
|
||||
// building options that might not lead to a respawn. A fresh "Run OMP"
|
||||
// click has no _muxSession yet and must never inherit whatever omp
|
||||
// conversation happens to be newest on disk for this working directory
|
||||
// (reported live 2026-08-27, fixed in 13a19f79); this guard keeps that
|
||||
// fix intact now that resolution has moved out of the eager options build.
|
||||
if (!this._muxSession) return;
|
||||
const resolvedId = resolveAndClaimOmpSessionId(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;
|
||||
}
|
||||
// Nothing unclaimed on disk (the dying process never got far enough to
|
||||
// write a session file, or a sibling already claimed the only candidate)
|
||||
// — fall back to the CLI's own "most recent" heuristic.
|
||||
console.warn(
|
||||
`[Session] OMP: no session file found under ${this.workingDir} to pin --resume on respawn; falling back to ambiguous --continue`
|
||||
);
|
||||
this._ompConfig = { ...this._ompConfig, continueSession: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember whether the CLI currently wants to be told about mouse clicks.
|
||||
*
|
||||
@@ -1877,6 +1953,7 @@ export class Session extends EventEmitter {
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
ompConfig: this._ompConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
envOverrides: this._envOverrides,
|
||||
effort: this._effort,
|
||||
@@ -1888,8 +1965,14 @@ 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. `_pinOmpRespawnId()` (called just above, inside
|
||||
// `_setupOrAttachMuxSession()`'s dead-pane branch) 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
|
||||
@@ -2007,7 +2090,12 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
|
||||
this._claudeSessionId = this._resumeSessionId || this.id;
|
||||
// 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).
|
||||
this._claudeSessionId = this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id;
|
||||
|
||||
this._pid = this.ptyProcess.pid;
|
||||
console.log('[Session] Interactive PTY spawned with PID:', this._pid);
|
||||
@@ -2298,10 +2386,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 _pinOmpRespawnId} 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 = resolveAndClaimOmpSessionId(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
|
||||
|
||||
+51
-3
@@ -54,6 +54,7 @@ import {
|
||||
type PiConfig,
|
||||
type GrokConfig,
|
||||
type DeepSeekConfig,
|
||||
type OmpConfig,
|
||||
type SessionRemote,
|
||||
type SessionDocker,
|
||||
type DockerCommandMode,
|
||||
@@ -99,6 +100,8 @@ import {
|
||||
resolveDeepSeekDir,
|
||||
getDeepSeekNotFoundMessage,
|
||||
resolveDefaultDeepSeekProfile,
|
||||
getOmpNotFoundMessage,
|
||||
resolveOmpDir,
|
||||
resolveLocalShell,
|
||||
loginShellArgs,
|
||||
} from './utils/index.js';
|
||||
@@ -896,6 +899,34 @@ function buildDeepSeekCommand(config?: DeepSeekConfig): string {
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the OMP CLI command with appropriate flags.
|
||||
*
|
||||
* omp reads its model routing and hooks from ~/.omp (agent dir), so no
|
||||
* trust/permission flags are needed: the CLI's own config governs. The only
|
||||
* CLI flags passed are the per-session overrides Codeman knows about.
|
||||
*/
|
||||
function buildOmpCommand(config?: OmpConfig): string {
|
||||
const parts = ['omp'];
|
||||
|
||||
if (config?.model) {
|
||||
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
|
||||
if (safeModel) parts.push('--model', safeModel);
|
||||
}
|
||||
|
||||
// --resume and --continue conflict; a valid explicit session id wins,
|
||||
// mirroring the sibling builders (grok/pi/opencode).
|
||||
const safeId =
|
||||
config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
|
||||
if (safeId) {
|
||||
parts.push('--resume', safeId);
|
||||
} else if (config?.continueSession) {
|
||||
parts.push('--continue');
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the spawn command for any session mode.
|
||||
* Shared by createSession() and respawnPane() to avoid duplication.
|
||||
@@ -941,6 +972,7 @@ export function buildSpawnCommand(options: {
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
resumeSessionId?: string;
|
||||
effort?: EffortLevel;
|
||||
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
|
||||
@@ -996,6 +1028,9 @@ export function buildSpawnCommand(options: {
|
||||
if (options.mode === 'deepseek') {
|
||||
return buildDeepSeekCommand(options.deepSeekConfig);
|
||||
}
|
||||
if (options.mode === 'omp') {
|
||||
return buildOmpCommand(options.ompConfig);
|
||||
}
|
||||
// #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"`
|
||||
// argument of the respawn-pane line, which execSync runs through `/bin/sh -c`,
|
||||
// so a `$SHELL` here is expanded by the SERVER process's shell against the
|
||||
@@ -1179,7 +1214,6 @@ export function buildRemoteKillCommand(options: { remote: SessionRemote; session
|
||||
* adopts/resizes/respawns our session (same defence as the remote socket).
|
||||
*/
|
||||
const DOCKER_TMUX_SOCKET = 'codeman-docker';
|
||||
|
||||
/**
|
||||
* Deterministic, reattach-stable in-container tmux session name. Derived from the
|
||||
* same stable field the local muxName uses (first 8 chars of the sessionId), so a
|
||||
@@ -1214,6 +1248,7 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri
|
||||
case 'grok':
|
||||
return `${modeCommand} --resume ${resumeId}`;
|
||||
case 'deepseek':
|
||||
case 'omp':
|
||||
return `${modeCommand} --resume ${resumeId}`;
|
||||
default:
|
||||
return modeCommand; // shell / opencode: no resume
|
||||
@@ -1810,7 +1845,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
mode === 'antigravity' ||
|
||||
mode === 'pi' ||
|
||||
mode === 'grok' ||
|
||||
mode === 'deepseek'
|
||||
mode === 'deepseek' ||
|
||||
mode === 'omp'
|
||||
? 'export COLORTERM=truecolor'
|
||||
: 'unset COLORTERM',
|
||||
...(mode === 'codex' ||
|
||||
@@ -1818,7 +1854,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
mode === 'antigravity' ||
|
||||
mode === 'pi' ||
|
||||
mode === 'grok' ||
|
||||
mode === 'deepseek'
|
||||
mode === 'deepseek' ||
|
||||
mode === 'omp'
|
||||
? ['unset NO_COLOR']
|
||||
: []),
|
||||
// Stamp each Codex pane with a unique originator so the response-viewer
|
||||
@@ -1923,6 +1960,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
const dir = resolveDeepSeekDir();
|
||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||
}
|
||||
if (mode === 'omp') {
|
||||
const dir = resolveOmpDir();
|
||||
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
|
||||
}
|
||||
return { pathExport: '', dir: null };
|
||||
}
|
||||
|
||||
@@ -2033,6 +2074,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
effort,
|
||||
@@ -2099,6 +2141,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
if (mode === 'grok' && !cliDir) {
|
||||
throw new Error(getGrokNotFoundMessage());
|
||||
}
|
||||
if (mode === 'omp' && !cliDir) {
|
||||
throw new Error(getOmpNotFoundMessage());
|
||||
}
|
||||
|
||||
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||||
|
||||
@@ -2115,6 +2160,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
sessionName: name,
|
||||
@@ -2345,6 +2391,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
envOverrides,
|
||||
effort,
|
||||
@@ -2376,6 +2423,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
sessionName: name,
|
||||
|
||||
+17
-4
@@ -8,7 +8,7 @@
|
||||
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
||||
* - SessionOutput — captured stdout/stderr/exitCode
|
||||
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' (which CLI backend)
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' (which CLI backend)
|
||||
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
|
||||
* - SessionColor — visual differentiation color
|
||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
||||
@@ -55,11 +55,12 @@ export type SessionMode =
|
||||
| 'antigravity'
|
||||
| 'pi'
|
||||
| 'grok'
|
||||
| 'deepseek';
|
||||
| 'deepseek'
|
||||
| 'omp';
|
||||
|
||||
export type RemoteCommandMode = Extract<
|
||||
SessionMode,
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek'
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -168,7 +169,7 @@ export interface RemoteSessionInfo {
|
||||
/** Which CLI backends a Docker case can run (same set as remote). */
|
||||
export type DockerCommandMode = Extract<
|
||||
SessionMode,
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek'
|
||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
|
||||
>;
|
||||
|
||||
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
|
||||
@@ -343,6 +344,16 @@ export interface AntigravityConfig {
|
||||
resumeConversationId?: string;
|
||||
}
|
||||
|
||||
/** OMP CLI session configuration */
|
||||
export interface OmpConfig {
|
||||
/** Model identifier (e.g., "crof/glm-5.2"). Passed via --model. */
|
||||
model?: string;
|
||||
/** Resume a previous conversation (passed via --resume). */
|
||||
resumeSessionId?: string;
|
||||
/** Continue the most recent session in this directory (passed via --continue). */
|
||||
continueSession?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pi CLI (pi.dev) session configuration.
|
||||
*
|
||||
@@ -620,6 +631,8 @@ export interface SessionState {
|
||||
grokConfig?: GrokConfig;
|
||||
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
/** OMP-specific configuration (only for mode === 'omp') */
|
||||
ompConfig?: OmpConfig;
|
||||
/** Claude conversation session ID to resume after reboot (set by restore script) */
|
||||
resumeSessionId?: string;
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
|
||||
@@ -67,3 +67,4 @@ export {
|
||||
export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolver.js';
|
||||
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
||||
export type { FileQueryMatcher } from './file-query.js';
|
||||
export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js';
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @fileoverview Resolve the OMP CLI binary across common install paths.
|
||||
*
|
||||
* Uses the shared `createCliExecutableResolver` (cli-executable-resolver.ts),
|
||||
* same as the sibling claude/opencode/codex/gemini/antigravity/pi resolvers:
|
||||
* server process PATH first, then common install directories, then — last,
|
||||
* because it is the only step that spawns anything — an interactive login
|
||||
* shell, which is what finds nvm/Homebrew/user-npm installs when Codeman runs
|
||||
* as a systemd/launchd service with a minimal PATH.
|
||||
*
|
||||
* Provides an augmented PATH directory for tmux sessions.
|
||||
*
|
||||
* @module utils/omp-cli-resolver
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import {
|
||||
createCliExecutableResolver,
|
||||
formatCliNotFoundMessage,
|
||||
type CliResolverHost,
|
||||
} from './cli-executable-resolver.js';
|
||||
|
||||
/**
|
||||
* Common directories where the OMP CLI binary may be installed. `~/.local/bin`
|
||||
* leads: omp.sh's installer targets `$HOME/.local/bin` with no `--dir`
|
||||
* override (verified against a real `--no-cache` Docker build — see
|
||||
* docker/agent.Dockerfile); `~/.omp/bin` was an unverified guess that turned
|
||||
* out wrong, kept after `~/.local/bin` only as a defensive fallback.
|
||||
*/
|
||||
const OMP_SEARCH_DIRS = [
|
||||
join(homedir(), '.local', 'bin'),
|
||||
join(homedir(), '.omp', 'bin'),
|
||||
'/usr/local/bin',
|
||||
join(homedir(), '.bun', 'bin'),
|
||||
join(homedir(), '.npm-global', 'bin'),
|
||||
join(homedir(), 'bin'),
|
||||
];
|
||||
|
||||
/**
|
||||
* A real `omp --version` prints `omp/<semver>` (e.g. `omp/17.4.0`).
|
||||
*
|
||||
* Shape mirrors PI_VERSION_REGEX: a capturing group and a leading boundary so
|
||||
* `omp/17.4.0` matches while an unrelated `omp` (some other program) does not.
|
||||
*/
|
||||
export const OMP_VERSION_REGEX = /(?:^|\s)omp\/(\d+\.\d+\.\d+)/;
|
||||
|
||||
const OMP_NOT_FOUND = 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh';
|
||||
|
||||
/**
|
||||
* Run `omp --version` on a candidate path and return the trimmed version when
|
||||
* it looks like the coding agent. Returns null for anything else — a missing
|
||||
* binary, a non-zero exit, a hang (timeout), or output that is not
|
||||
* `omp/<semver>`-shaped (which is how an unrelated `omp` on PATH gets rejected).
|
||||
*
|
||||
* Never runs under vitest: the suites must stay hermetic and must not depend on
|
||||
* whether the dev box happens to have omp installed. The shared resolver host
|
||||
* is already inert under vitest, so this gate is defense in depth for any
|
||||
* opted-in host that still carries the default probe.
|
||||
*/
|
||||
function probeOmpVersion(binPath: string): string | null {
|
||||
if (process.env.VITEST) return null;
|
||||
try {
|
||||
const out = execFileSync(binPath, ['--version'], {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
// A stuck or hostile `omp` that ignores SIGTERM would survive the timeout
|
||||
// and block the server (execFileSync keeps waiting after the signal).
|
||||
killSignal: 'SIGKILL',
|
||||
}).trim();
|
||||
const candidate = OMP_VERSION_REGEX.exec(out)?.[1];
|
||||
if (candidate) return candidate;
|
||||
console.warn(`[OmpResolver] Ignoring ${binPath}: "omp --version" printed ${JSON.stringify(out.slice(0, 80))}`);
|
||||
} catch (err) {
|
||||
console.warn(`[OmpResolver] Ignoring ${binPath}: "omp --version" failed (${(err as Error).message})`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type OmpVersionProbe = (binPath: string) => string | null;
|
||||
|
||||
function createOmpResolver(
|
||||
host?: CliResolverHost,
|
||||
versionProbe: OmpVersionProbe = probeOmpVersion,
|
||||
now?: () => number
|
||||
) {
|
||||
return createCliExecutableResolver<string>(
|
||||
{
|
||||
binary: 'omp',
|
||||
searchDirs: OMP_SEARCH_DIRS,
|
||||
validateCandidate: (binPath) => {
|
||||
const version = versionProbe(binPath);
|
||||
return version ? { accepted: true, metadata: version } : { accepted: false };
|
||||
},
|
||||
now,
|
||||
},
|
||||
host
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an isolated OMP wrapper around an injected host, version probe and
|
||||
* clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which
|
||||
* is exactly what the hermeticity test exercises.
|
||||
*/
|
||||
export function createOmpResolverForTest(host: CliResolverHost, versionProbe?: OmpVersionProbe, now?: () => number) {
|
||||
return createOmpResolver(host, versionProbe ?? probeOmpVersion, now);
|
||||
}
|
||||
|
||||
const ompResolver = createOmpResolver();
|
||||
|
||||
/**
|
||||
* Finds the directory containing a verified `omp` binary.
|
||||
* Checks `which omp` first, then falls back to common install locations. Every
|
||||
* candidate must pass the `omp --version` sanity probe before it is accepted.
|
||||
*
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveOmpDir(): string | null {
|
||||
return ompResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the OMP CLI is available on the system.
|
||||
*/
|
||||
export function isOmpAvailable(): boolean {
|
||||
return resolveOmpDir() !== null;
|
||||
}
|
||||
|
||||
export function getOmpNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(OMP_NOT_FOUND, ompResolver.diagnostics());
|
||||
}
|
||||
|
||||
/**
|
||||
* Version reported by the resolved `omp` binary, or null when omp is
|
||||
* unavailable. Surfaced through `GET /api/omp/status` so a misresolution is
|
||||
* diagnosable from the UI.
|
||||
*/
|
||||
export function getOmpCliVersion(): string | null {
|
||||
return ompResolver.resolve()?.metadata ?? null;
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* @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 { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, sep } 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. Confirmed empirically against real
|
||||
* `~/.omp/agent/sessions/` directory names (2026-08-27): unlike Claude Code's
|
||||
* `~/.claude/projects/*`, which keeps the home prefix (`-home-user-dev-foo`),
|
||||
* omp collapses a home-relative workingDir to its home-relative remainder
|
||||
* FIRST (`/home/user/dev/foo` -> `/dev/foo`) and only then dash-replaces
|
||||
* (`-dev-foo`) — a path outside $HOME (e.g. `/tmp/...`) is dash-replaced as-is.
|
||||
* Getting this wrong doesn't error, it just silently returns an empty
|
||||
* directory listing: findLatestOmpSessionId() below then always falls through
|
||||
* to null, so continuation pinning quietly degrades to omp's own ambiguous
|
||||
* `--continue` for every case under $HOME (i.e. virtually all real Codeman
|
||||
* cases) while appearing to work in `/tmp`-based manual testing.
|
||||
* Pure so it's unit-testable without touching the filesystem.
|
||||
*/
|
||||
export function mangleOmpWorkingDir(workingDir: string): string {
|
||||
// UNVERIFIED EDGE CASE: if $HOME is itself a symlink, this compares against
|
||||
// the literal homedir() string, not a realpath()-resolved one. Whether that
|
||||
// matches omp's own behavior is unconfirmed — we only empirically verified
|
||||
// omp strips a literal $HOME prefix (2026-08-27), not that it canonicalizes
|
||||
// symlinks first. Do not "fix" this with realpathSync() without confirming
|
||||
// omp's actual behavior on a symlinked-home setup; guessing wrong here would
|
||||
// trade one silent mismatch for a different one.
|
||||
const home = homedir();
|
||||
const relative =
|
||||
workingDir === home || workingDir.startsWith(home + sep) ? workingDir.slice(home.length) : workingDir;
|
||||
return relative.replace(/\//g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* `~/.omp` — omp's own env overrides are mostly `PI_*` (shared with pi mode, already
|
||||
* allowlisted in schemas.ts), and `PI_CONFIG_DIR` in particular can move this root.
|
||||
* That is not honored here: a session with a redirected `PI_CONFIG_DIR` silently
|
||||
* degrades pinning/history to omp's own ambiguous `--continue` instead of erroring,
|
||||
* a known gap (found in Ark0N/Codeman#353 review) shared with pi and not fixed here.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The session header line is always near the top of the file (the
|
||||
* transcript's own "second line" — see omp-transcript.ts), so identifying a
|
||||
* file never needs reading the whole thing (up to multi-MB, per that same
|
||||
* module's size cap). Bounded read only.
|
||||
*/
|
||||
const HEADER_READ_BYTES = 8 * 1024;
|
||||
|
||||
function readOmpSessionHeader(filePath: string): { id: string; cwd: string } | null {
|
||||
let raw: string;
|
||||
try {
|
||||
const fd = openSync(filePath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(HEADER_READ_BYTES);
|
||||
const bytesRead = readSync(fd, buf, 0, HEADER_READ_BYTES, 0);
|
||||
raw = buf.toString('utf-8', 0, bytesRead);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
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') {
|
||||
return { id: e.id, cwd: e.cwd };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-wide registry of OMP session ids already pinned to a live Codeman
|
||||
* session. Two omp tabs in the same case dir (`w1-foo`, `w2-foo`) resolve
|
||||
* against the SAME directory on disk — without this, both could pick the
|
||||
* newest file and alias onto each other's conversation (found in upstream PR
|
||||
* review, Ark0N/Codeman#353). Never released: this holds at most a handful of
|
||||
* short ids per real omp conversation ever pinned in this process's lifetime,
|
||||
* immaterial memory even after weeks of uptime — correctness here matters
|
||||
* more than reclaiming it.
|
||||
*/
|
||||
const claimedOmpSessionIds = new Set<string>();
|
||||
|
||||
/**
|
||||
* Safe variant of {@link findLatestOmpSessionId} for callers where two omp
|
||||
* sessions CAN share the same case directory — a dead-pane respawn, a
|
||||
* boot-recovery reattach, or a first-idle capture — instead of the narrower
|
||||
* cases where "newest file" is unambiguous by construction. Verifies each
|
||||
* candidate's own header `cwd` against `workingDir` (mangling is a lossy
|
||||
* one-way transform — see {@link mangleOmpWorkingDir} — so trusting the
|
||||
* filename-derived id alone isn't enough) and skips any id a sibling session
|
||||
* has already claimed. Claims the id it returns so a concurrent caller
|
||||
* resolving the same directory in the same tick can't double-claim it.
|
||||
*/
|
||||
export function resolveAndClaimOmpSessionId(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) {
|
||||
if (!OMP_SESSION_FILE_PATTERN.test(entry)) continue;
|
||||
const filePath = join(dir, entry);
|
||||
let mtimeMs: number;
|
||||
try {
|
||||
mtimeMs = statSync(filePath).mtimeMs;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (mtimeMs <= newestMtime) continue;
|
||||
const header = readOmpSessionHeader(filePath);
|
||||
if (!header || header.cwd !== workingDir || claimedOmpSessionIds.has(header.id)) continue;
|
||||
newestMtime = mtimeMs;
|
||||
newestId = header.id;
|
||||
}
|
||||
if (newestId) claimedOmpSessionIds.add(newestId);
|
||||
return newestId;
|
||||
}
|
||||
@@ -2270,9 +2270,11 @@ class CodemanApp {
|
||||
? 'Grok'
|
||||
: mode === 'deepseek'
|
||||
? 'DeepSeek'
|
||||
: mode === 'opencode'
|
||||
? 'OpenCode'
|
||||
: 'Claude';
|
||||
: mode === 'omp'
|
||||
? 'OMP'
|
||||
: mode === 'opencode'
|
||||
? 'OpenCode'
|
||||
: 'Claude';
|
||||
}
|
||||
|
||||
async toggleResponseViewer() {
|
||||
@@ -4901,7 +4903,7 @@ class CodemanApp {
|
||||
<span class="tab-status ${status}" aria-hidden="true"></span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row">
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : ''}
|
||||
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : mode === 'omp' ? '<span class="tab-mode omp" aria-hidden="true">om</span>' : ''}
|
||||
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
|
||||
${inlineSessionActions ? tabActionsHtml : ''}
|
||||
<span class="tab-detached-badge" aria-hidden="true">detached</span>
|
||||
@@ -6349,7 +6351,9 @@ class CodemanApp {
|
||||
? 'Kill Tmux & Grok'
|
||||
: session.mode === 'deepseek'
|
||||
? 'Kill Tmux & DeepSeek'
|
||||
: 'Kill Tmux & Claude Code';
|
||||
: session.mode === 'omp'
|
||||
? 'Kill Tmux & OMP'
|
||||
: 'Kill Tmux & Claude Code';
|
||||
}
|
||||
|
||||
document.getElementById('closeConfirmModal').classList.add('active');
|
||||
|
||||
@@ -80,6 +80,7 @@ const HOME_SESSIONS_MODE_BADGE = {
|
||||
pi: 'pi',
|
||||
grok: 'gk',
|
||||
deepseek: 'ds',
|
||||
omp: 'om',
|
||||
};
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
|
||||
@@ -452,6 +452,10 @@
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run DeepSeek
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-omp" id="welcomeOmpBtn" style="display: none;" onclick="app.setRunMode('omp'); app.runOmp()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run OMP
|
||||
</button>
|
||||
</div>
|
||||
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
|
||||
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
|
||||
@@ -643,6 +647,9 @@
|
||||
<button class="run-mode-option run-mode-option-install" data-action="deepseek-install" id="runModeDeepSeekInstall" style="display: none;" onclick="app.installDeepSeekProfile()">
|
||||
<span class="run-mode-dot deepseek"></span>DeepSeek — add a terminal profile…
|
||||
</button>
|
||||
<button class="run-mode-option" data-mode="omp" onclick="app.setRunMode('omp')">
|
||||
<span class="run-mode-dot omp"></span>OMP
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
|
||||
<span class="run-mode-dot shell"></span>Terminal / Shell
|
||||
@@ -930,6 +937,7 @@
|
||||
<option value="pi">Pi</option>
|
||||
<option value="grok">Grok</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="omp">OMP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
|
||||
@@ -2709,6 +2717,7 @@
|
||||
<option value="pi" data-cli="pi">Pi</option>
|
||||
<option value="grok" data-cli="grok">Grok</option>
|
||||
<option value="deepseek" data-cli="deepseek">DeepSeek</option>
|
||||
<option value="omp" data-cli="omp">OMP</option>
|
||||
<option value="shell">Shell (no agent)</option>
|
||||
</select>
|
||||
<span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span>
|
||||
|
||||
@@ -56,6 +56,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
|
||||
{ mode: 'pi', label: 'Pi', short: 'Pi' },
|
||||
{ mode: 'grok', label: 'Grok', short: 'Grok' },
|
||||
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
|
||||
{ mode: 'omp', label: 'OMP', short: 'OMP' },
|
||||
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
|
||||
];
|
||||
|
||||
@@ -388,7 +389,7 @@ 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);
|
||||
await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined, row.mode);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -1003,6 +1003,20 @@ html.mobile-init .file-browser-panel {
|
||||
border-color: rgba(150, 170, 255, 0.55) !important;
|
||||
}
|
||||
|
||||
/* OMP mode colors on mobile. Same `!important` rationale as the pi/grok/deepseek blocks above. */
|
||||
.btn-toolbar.btn-run.mode-omp,
|
||||
.btn-toolbar.btn-run-gear.mode-omp {
|
||||
background: #312e81 !important;
|
||||
border-color: rgba(129, 140, 248, 0.3) !important;
|
||||
color: #e0e7ff !important;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-run.mode-omp:active,
|
||||
.btn-toolbar.btn-run-gear.mode-omp:active {
|
||||
background: #4f46e5 !important;
|
||||
border-color: rgba(129, 140, 248, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Run mode dropdown menu — positioned above toolbar on mobile */
|
||||
.run-mode-menu {
|
||||
bottom: 100%;
|
||||
@@ -3086,6 +3100,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-omp, .btn-toolbar.btn-run-gear.mode-omp) {
|
||||
background: linear-gradient(135deg, #4f46e5, #6366f1);
|
||||
border-color: #4338ca;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-grok, .btn-toolbar.btn-run-gear.mode-grok) {
|
||||
background: linear-gradient(135deg, #27272a, #52525b);
|
||||
border-color: #18181b;
|
||||
|
||||
@@ -432,7 +432,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
_buildCommandPaletteNewSessionItem(query = '') {
|
||||
const mode = this.runMode || this._runMode || 'claude';
|
||||
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek' };
|
||||
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP' };
|
||||
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
|
||||
return {
|
||||
id: 'new-session',
|
||||
@@ -670,7 +670,7 @@ 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);
|
||||
void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir, undefined, s.mode);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -400,6 +400,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (mode === 'antigravity') {
|
||||
return await this.runAntigravity();
|
||||
}
|
||||
if (mode === 'omp') {
|
||||
return await this.runOmp();
|
||||
}
|
||||
if (mode === 'pi') {
|
||||
return await this.runPi();
|
||||
}
|
||||
@@ -474,7 +477,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
|
||||
*/
|
||||
_refreshRunModeAvailability(menu) {
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) {
|
||||
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']) {
|
||||
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
|
||||
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
|
||||
}
|
||||
@@ -689,7 +692,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
btn.append(...parts);
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.resumeHistorySession(s.sessionId, s.workingDir, s.name);
|
||||
this.resumeHistorySession(s.sessionId, s.workingDir, s.name, s.mode);
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
@@ -710,7 +713,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
|
||||
}
|
||||
if (label) {
|
||||
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'shell' ? 'Run SH' : 'Run';
|
||||
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'omp' ? 'Run OMP' : mode === 'shell' ? 'Run SH' : 'Run';
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1423,6 +1426,56 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
async runOmp() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote/docker cases run omp on the OTHER side — skip the local status probe
|
||||
// and the local-only config below (quick-start rejects them for remote cases).
|
||||
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
|
||||
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
|
||||
|
||||
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OMP session in ${caseName}...`);
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/omp/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this._reportSessionLaunchError(
|
||||
ownsLaunchTerminal,
|
||||
'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh'
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'omp',
|
||||
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
|
||||
...(isRemote ? {} : {
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start OMP');
|
||||
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
|
||||
|
||||
if (data.data.sessionId) {
|
||||
await this.selectSession(data.data.sessionId);
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
} catch (err) {
|
||||
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Launch a Grok Build (xAI `grok`) session.
|
||||
*
|
||||
@@ -1626,7 +1679,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
|
||||
|
||||
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
|
||||
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek';
|
||||
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
|
||||
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
|
||||
|
||||
// Update respawn status display and buttons
|
||||
@@ -1656,7 +1709,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
|
||||
// Hide Claude-specific options for external CLI sessions
|
||||
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek';
|
||||
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
|
||||
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
|
||||
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
|
||||
|
||||
@@ -3442,7 +3495,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
|
||||
},
|
||||
set(mode) {
|
||||
this._runMode =
|
||||
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'claude'
|
||||
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'omp' || mode === 'claude'
|
||||
? mode
|
||||
: 'claude';
|
||||
},
|
||||
|
||||
@@ -1230,6 +1230,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
['welcomeClaudeBtn', 'claude'],
|
||||
['welcomeOpencodeBtn', 'opencode'],
|
||||
['welcomeAntigravityBtn', 'antigravity'],
|
||||
['welcomeOmpBtn', 'omp'],
|
||||
['welcomeGeminiBtn', 'gemini'],
|
||||
['welcomePiBtn', 'pi'],
|
||||
['welcomeGrokBtn', 'grok'],
|
||||
|
||||
@@ -349,7 +349,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
.session-tab .tab-mode.gemini,
|
||||
.session-tab .tab-mode.antigravity,
|
||||
.session-tab .tab-mode.pi,
|
||||
.session-tab .tab-mode.grok
|
||||
.session-tab .tab-mode.grok,
|
||||
.session-tab .tab-mode.omp
|
||||
) {
|
||||
color: var(--accent-d);
|
||||
}
|
||||
@@ -2499,6 +2500,10 @@ body.solo-mode .btn-lifecycle-log {
|
||||
background: rgba(34, 211, 238, 0.2);
|
||||
color: #22d3ee;
|
||||
}
|
||||
.session-tab .tab-mode.omp {
|
||||
background: rgba(129, 140, 248, 0.2);
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.session-tab .tab-mode.pi {
|
||||
background: rgba(244, 114, 182, 0.2);
|
||||
@@ -3883,6 +3888,22 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
color: #fff1f7;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
/* OMP: indigo identity, matching .btn-toolbar.btn-run.mode-omp and
|
||||
.run-mode-dot.omp so the welcome action reads as the same backend. */
|
||||
.welcome-btn-omp {
|
||||
background: linear-gradient(135deg, #1e1b4b 0%, #4f46e5 55%, #6366f1 100%);
|
||||
border-color: rgba(129, 140, 248, 0.4);
|
||||
color: #e0e7ff;
|
||||
box-shadow: 0 2px 8px rgba(129, 140, 248, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.welcome-btn-omp:hover {
|
||||
background: linear-gradient(135deg, #312e81 0%, #6366f1 55%, #818cf8 100%);
|
||||
box-shadow: 0 4px 20px rgba(129, 140, 248, 0.3), 0 0 40px rgba(79, 70, 229, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(165, 180, 252, 0.5);
|
||||
color: #eef2ff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Grok (xAI): monochrome charcoal identity, matching .btn-toolbar.btn-run.mode-grok
|
||||
and .run-mode-dot.grok so the welcome action reads as the same backend. */
|
||||
@@ -4992,6 +5013,21 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
border-color: rgba(249, 168, 212, 0.6);
|
||||
color: #fff1f7;
|
||||
}
|
||||
/* OMP mode colors */
|
||||
.btn-toolbar.btn-run.mode-omp,
|
||||
.btn-toolbar.btn-run-gear.mode-omp {
|
||||
background: linear-gradient(135deg, #312e81 0%, #4f46e5 55%, #6366f1 100%);
|
||||
border-color: rgba(129, 140, 248, 0.5);
|
||||
color: #e0e7ff;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-omp:hover,
|
||||
.btn-toolbar.btn-run-gear.mode-omp:hover {
|
||||
background: linear-gradient(135deg, #3730a3 0%, #6366f1 55%, #818cf8 100%);
|
||||
box-shadow: 0 0 12px rgba(129, 140, 248, 0.35), 0 2px 8px rgba(79, 70, 229, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(165, 180, 252, 0.6);
|
||||
color: #eef2ff;
|
||||
}
|
||||
|
||||
/* Grok mode colors. Same cascade note as pi above: this base-sheet pair only
|
||||
renders on the `og` skin — the nested `html:not([data-skin="og"])` block
|
||||
@@ -5116,6 +5152,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
||||
.run-mode-dot.pi { background: #f472b6; }
|
||||
.run-mode-dot.grok { background: #a1a1aa; }
|
||||
.run-mode-dot.deepseek { background: #4d6bfe; }
|
||||
.run-mode-dot.omp { background: #818cf8; }
|
||||
.run-mode-dot.shell { background: #94a3b8; }
|
||||
|
||||
/* Phone-only Enter button (see index.html). Hidden by default at every width;
|
||||
|
||||
@@ -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);
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -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);
|
||||
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode);
|
||||
}
|
||||
this.closeSessionManager?.();
|
||||
closeMenu();
|
||||
@@ -2904,7 +2904,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
return `w${startNumber}-${dirName}`;
|
||||
},
|
||||
|
||||
async resumeHistorySession(sessionId, workingDir, existingName) {
|
||||
async resumeHistorySession(sessionId, workingDir, existingName, mode) {
|
||||
// Close the run mode menu if open
|
||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||
// Close folder history modal if open
|
||||
@@ -2925,13 +2925,45 @@ Object.assign(CodemanApp.prototype, {
|
||||
const globalSettings = this.loadAppSettingsFromStorage();
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
|
||||
const effort = this.getEffortSetting(globalSettings);
|
||||
// `resumeSessionId` is a Claude conversation UUID (server reads it from
|
||||
// ~/.claude/projects); an external-CLI row has no such thing, so sending
|
||||
// it there gets silently ignored while the OMITTED `mode` field defaults
|
||||
// the create to plain claude — reproducing whatever conversation THAT
|
||||
// uuid happens to collide with instead of the row's own backend. Row mode
|
||||
// wins here. Codeman has no cross-restart PTY-reattach outside server
|
||||
// boot, so "resume" for a non-claude row means relaunching the CLI's own
|
||||
// continue-most-recent flag (opencode/pi/grok/omp --continue, deepseek
|
||||
// resumeSession) in the same directory — real conversation continuity,
|
||||
// just not the literal old process.
|
||||
const effectiveMode = mode || 'claude';
|
||||
const modeConfigKey = {
|
||||
opencode: 'openCodeConfig',
|
||||
pi: 'piConfig',
|
||||
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.
|
||||
const modeConfig =
|
||||
modeConfigKey
|
||||
? { [modeConfigKey]: { continueSession: true } }
|
||||
: effectiveMode === 'deepseek'
|
||||
? { deepSeekConfig: { resumeSession: true } }
|
||||
: {};
|
||||
const continuesSomething = Boolean(modeConfigKey) || effectiveMode === 'deepseek';
|
||||
const createRes = await fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workingDir,
|
||||
name,
|
||||
resumeSessionId: sessionId,
|
||||
mode: effectiveMode,
|
||||
...(effectiveMode === 'claude' ? { resumeSessionId: sessionId } : {}),
|
||||
...modeConfig,
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
}),
|
||||
@@ -2944,6 +2976,21 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Start interactive
|
||||
await fetch(`/api/sessions/${newSessionId}/interactive`, { method: 'POST' });
|
||||
|
||||
// Retire the row being resumed: a non-claude "resume" is really a brand
|
||||
// new Codeman session pointed at the same directory (there is no id to
|
||||
// reattach to), so without this every resume leaves the old row behind
|
||||
// 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
|
||||
// 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.
|
||||
if (effectiveMode !== 'claude' && continuesSomething && sessionId !== newSessionId) {
|
||||
fetch(`/api/sessions/${sessionId}?killMux=true`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
|
||||
this.terminal.writeln(`\x1b[90m Session ${name} ready\x1b[0m`);
|
||||
await this.selectSession(newSessionId);
|
||||
this.terminal.focus();
|
||||
|
||||
@@ -12,7 +12,7 @@ import { homedir } from 'node:os';
|
||||
import type { z } from 'zod';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { Session } from '../session.js';
|
||||
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, type AuthUser, type SessionState } from '../types.js';
|
||||
import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js';
|
||||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
@@ -264,6 +264,18 @@ export function revokeUserSessions(
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 404 both session-lookup helpers below throw. A missing session and one
|
||||
* the caller isn't allowed to see get the IDENTICAL error (never 403), so
|
||||
* existence of another user's session is never leaked.
|
||||
*/
|
||||
function sessionNotFoundError(sessionId: string): Error & { statusCode: number; body: unknown } {
|
||||
return Object.assign(new Error(`Session ${sessionId} not found`), {
|
||||
statusCode: 404,
|
||||
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a session by ID or throw a structured error.
|
||||
* Replaces the pattern: `const session = sessions.get(id); if (!session) return createErrorResponse(...)`.
|
||||
@@ -274,15 +286,31 @@ export function revokeUserSessions(
|
||||
*/
|
||||
export function findSessionOrFail(ctx: SessionPort, sessionId: string, req?: FastifyRequest): Session {
|
||||
const session = ctx.sessions.get(sessionId);
|
||||
if (!session || (req && !canAccessOwned(getAuthUser(req), session.owner))) {
|
||||
throw Object.assign(new Error(`Session ${sessionId} not found`), {
|
||||
statusCode: 404,
|
||||
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
|
||||
});
|
||||
}
|
||||
if (!session) throw sessionNotFoundError(sessionId);
|
||||
if (req && !canAccessOwned(getAuthUser(req), session.owner)) throw sessionNotFoundError(sessionId);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link findSessionOrFail}, for a session that exists ONLY in persisted
|
||||
* state — a resumed-but-never-reattached row (e.g. a non-claude "Resume" that
|
||||
* relaunched into a new session and wants to retire the row it can no longer
|
||||
* reattach to) has no live `Session` instance for `findSessionOrFail` to
|
||||
* return, so this returns the persisted record instead. Same ownership
|
||||
* enforcement, same 404-not-403 leak protection — this is that function's
|
||||
* missing other half, not a separate check reimplemented inline.
|
||||
*/
|
||||
export function findPersistedSessionOrFail(
|
||||
store: { getSession(id: string): SessionState | null },
|
||||
sessionId: string,
|
||||
req?: FastifyRequest
|
||||
): SessionState {
|
||||
const persisted = store.getSession(sessionId);
|
||||
if (!persisted) throw sessionNotFoundError(sessionId);
|
||||
if (req && !canAccessOwned(getAuthUser(req), persisted.owner)) throw sessionNotFoundError(sessionId);
|
||||
return persisted;
|
||||
}
|
||||
|
||||
/** Shortest prefix accepted for a parent session id (see resolveParentSessionId). */
|
||||
const PARENT_SESSION_ID_MIN_PREFIX = 8;
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -65,6 +67,7 @@ import {
|
||||
autoConfigureRalph,
|
||||
canAccessOwned,
|
||||
CASES_DIR,
|
||||
findPersistedSessionOrFail,
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
isAdmin,
|
||||
@@ -136,6 +139,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,
|
||||
@@ -395,10 +400,10 @@ export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
|
||||
|
||||
/**
|
||||
* Env-var keys a non-granted owner must not be able to set, because each one
|
||||
* hands back privilege the config clamp above just removed — or, for the last,
|
||||
* redirects a credential the server injects.
|
||||
* hands back privilege the config clamp above just removed, or redirects a
|
||||
* credential-resolution endpoint.
|
||||
*
|
||||
* All are DeepSeek's, and all are reachable because `DSH_*` and `DEEPSEEK_*` are
|
||||
* The DeepSeek three are reachable because `DSH_*` and `DEEPSEEK_*` are
|
||||
* allowlisted `envOverrides` prefixes (schemas.ts) — which they have to be, since
|
||||
* that is also how a user configures the harness's non-privileged knobs.
|
||||
*
|
||||
@@ -415,8 +420,24 @@ export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
|
||||
* URL would have the operator's API key sent as a bearer credential to a host
|
||||
* of their choosing. (`DEEPSEEK_API_KEY` itself stays overridable: supplying
|
||||
* your OWN key removes privilege rather than granting it.)
|
||||
* - `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` are where omp resolves
|
||||
* credentials from — the same shape as `DEEPSEEK_BASE_URL` above, reachable
|
||||
* because `OMP_*` is an allowlisted prefix. Unlike DeepSeek, Codeman does not
|
||||
* forward any operator-held key into an omp pane today (omp's provider
|
||||
* credentials live in `~/.omp` config files, not env vars), so there is no
|
||||
* known concrete exfiltration path yet — clamped defensively anyway, since a
|
||||
* non-granted owner redirecting where a shared multi-tenant deployment
|
||||
* resolves auth from is not something to allow silently (found in
|
||||
* Ark0N/Codeman#353 review; omp's own knobs are otherwise mostly `PI_*`,
|
||||
* already allowlisted for pi and not addressed here — see resolveOmpHome()).
|
||||
*/
|
||||
const OWNER_CLAMPED_ENV_KEYS = ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'] as const;
|
||||
const OWNER_CLAMPED_ENV_KEYS = [
|
||||
'DSH_PERMISSION_MODE',
|
||||
'DSH_HOME',
|
||||
'DEEPSEEK_BASE_URL',
|
||||
'OMP_AUTH_BROKER_URL',
|
||||
'OMP_AUTH_BROKER_TOKEN',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Env-var half of the multi-user bypass clamp.
|
||||
@@ -744,6 +765,36 @@ 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.
|
||||
*/
|
||||
export 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);
|
||||
if (!resolvedId) {
|
||||
console.warn(
|
||||
`[Session] OMP: no session file found under ${workingDir} to pin --resume; falling back to ambiguous --continue`
|
||||
);
|
||||
}
|
||||
return resolvedId ? { ...ompConfig, resumeSessionId: resolvedId } : ompConfig;
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
|
||||
@@ -866,6 +917,7 @@ export function registerSessionRoutes(
|
||||
body.mode !== 'pi' &&
|
||||
body.mode !== 'grok' &&
|
||||
body.mode !== 'deepseek' &&
|
||||
body.mode !== 'omp' &&
|
||||
body.envOverrides &&
|
||||
Object.keys(body.envOverrides).length > 0 &&
|
||||
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
|
||||
@@ -965,6 +1017,12 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage());
|
||||
}
|
||||
}
|
||||
if (body.mode === 'omp') {
|
||||
const { isOmpAvailable, getOmpNotFoundMessage } = await import('../../utils/omp-cli-resolver.js');
|
||||
if (!isOmpAvailable()) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOmpNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-validate resumeSessionId: check that the conversation file actually exists
|
||||
// in Claude's projects directory. If not, skip resume to avoid confusing
|
||||
@@ -1012,12 +1070,14 @@ export function registerSessionRoutes(
|
||||
? body.piConfig?.model
|
||||
: mode === 'grok'
|
||||
? body.grokConfig?.model
|
||||
: // DeepSeek's model is a composition entry in the profile's config
|
||||
// tree, not a session flag, so there is deliberately nothing to
|
||||
// read here (see docs/deepseek-integration.md).
|
||||
mode !== 'shell' && mode !== 'deepseek'
|
||||
? modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
: mode === 'omp'
|
||||
? body.ompConfig?.model
|
||||
: // DeepSeek's model is a composition entry in the profile's config
|
||||
// tree, not a session flag, so there is deliberately nothing to
|
||||
// read here (see docs/deepseek-integration.md).
|
||||
mode !== 'shell' && mode !== 'deepseek'
|
||||
? modelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const claudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
// Section 6.3: force non-granted users to a classifier-guarded mode.
|
||||
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
|
||||
@@ -1056,6 +1116,7 @@ export function registerSessionRoutes(
|
||||
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
|
||||
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
|
||||
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
|
||||
ompConfig: resolveOmpConfigForCreate(mode, workingDir, body.ompConfig),
|
||||
resumeSessionId: validatedResumeId,
|
||||
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
|
||||
effort: body.effort,
|
||||
@@ -1124,9 +1185,26 @@ export function registerSessionRoutes(
|
||||
const query = req.query as { killMux?: string };
|
||||
const killMux = query.killMux !== 'false'; // Default to true
|
||||
|
||||
// Security: owner-scoped lookup 404s foreign/missing sessions uniformly (no existence leak, no cross-user kill).
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
// A resumed/detached-but-never-live row (e.g. a non-claude "Resume" that
|
||||
// relaunched into a NEW session and wants to retire the old one it can no
|
||||
// longer reattach to) has no entry in ctx.sessions at all — only in
|
||||
// persisted state. Fall back to removing that persisted record directly
|
||||
// rather than 404ing: the caller means "make this row go away", and a
|
||||
// stale duplicate row is exactly what's left behind otherwise. Pinned
|
||||
// sessions keep their existing demote-not-delete protection.
|
||||
if (!ctx.sessions.has(id)) {
|
||||
// Called for its existence/ownership 404 side effect only — demoteOrRemoveSession
|
||||
// below re-looks-up the record by id, so the returned SessionState is unused here.
|
||||
findPersistedSessionOrFail(ctx.store, id, req);
|
||||
ctx.store.demoteOrRemoveSession(id);
|
||||
// Mirrors the broadcast at the tail of the live-session cleanup path
|
||||
// (_doCleanupSession in server.ts) — without it, other open tabs keep
|
||||
// showing the retired row until their next unrelated fetch.
|
||||
ctx.broadcast(SseEvent.SessionDeleted, { id });
|
||||
return {};
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
await ctx.cleanupSession(session.id, killMux, 'user_delete');
|
||||
return {};
|
||||
});
|
||||
@@ -1289,6 +1367,7 @@ export function registerSessionRoutes(
|
||||
session.mode !== 'pi' &&
|
||||
session.mode !== 'grok' &&
|
||||
session.mode !== 'deepseek' &&
|
||||
session.mode !== 'omp' &&
|
||||
ctx.store.getConfig().ralphEnabled &&
|
||||
!session.ralphTracker.autoEnableDisabled
|
||||
) {
|
||||
@@ -2857,6 +2936,7 @@ export function registerSessionRoutes(
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
envOverrides,
|
||||
effort,
|
||||
parentSessionId,
|
||||
@@ -2907,6 +2987,7 @@ export function registerSessionRoutes(
|
||||
piConfig ||
|
||||
grokConfig ||
|
||||
deepSeekConfig ||
|
||||
ompConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
@@ -2941,6 +3022,7 @@ export function registerSessionRoutes(
|
||||
piConfig ||
|
||||
grokConfig ||
|
||||
deepSeekConfig ||
|
||||
ompConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
@@ -3042,6 +3124,16 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
|
||||
}
|
||||
}
|
||||
// Check OMP availability if requested
|
||||
if (mode === 'omp') {
|
||||
const { isOmpAvailable } = await import('../../utils/omp-cli-resolver.js');
|
||||
if (!isOmpAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check Grok availability if requested
|
||||
if (mode === 'grok') {
|
||||
@@ -3103,14 +3195,15 @@ export function registerSessionRoutes(
|
||||
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd);
|
||||
|
||||
// Write .claude/settings.local.json with hooks for desktop notifications
|
||||
// (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi and Grok use their own systems)
|
||||
// (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP use their own systems)
|
||||
if (
|
||||
mode !== 'opencode' &&
|
||||
mode !== 'codex' &&
|
||||
mode !== 'gemini' &&
|
||||
mode !== 'antigravity' &&
|
||||
mode !== 'pi' &&
|
||||
mode !== 'grok'
|
||||
mode !== 'grok' &&
|
||||
mode !== 'omp'
|
||||
) {
|
||||
await writeHooksConfig(resolvedCasePath);
|
||||
}
|
||||
@@ -3185,6 +3278,7 @@ export function registerSessionRoutes(
|
||||
mode !== 'pi' &&
|
||||
mode !== 'grok' &&
|
||||
mode !== 'deepseek' &&
|
||||
mode !== 'omp' &&
|
||||
!remote &&
|
||||
envOverrides &&
|
||||
Object.keys(envOverrides).length > 0
|
||||
@@ -3209,10 +3303,12 @@ export function registerSessionRoutes(
|
||||
? piConfig?.model
|
||||
: mode === 'grok'
|
||||
? grokConfig?.model
|
||||
: // DeepSeek's model lives in the profile's config tree, not here.
|
||||
mode !== 'shell' && mode !== 'deepseek'
|
||||
? qsModelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
: mode === 'omp'
|
||||
? ompConfig?.model
|
||||
: // DeepSeek's model lives in the profile's config tree, not here.
|
||||
mode !== 'shell' && mode !== 'deepseek'
|
||||
? qsModelConfig?.defaultModel || undefined
|
||||
: undefined;
|
||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
|
||||
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
|
||||
@@ -3252,6 +3348,7 @@ export function registerSessionRoutes(
|
||||
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
|
||||
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
|
||||
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
|
||||
ompConfig: resolveOmpConfigForCreate(mode, resolvedCasePath, ompConfig),
|
||||
envOverrides: qsGatedEnvOverrides,
|
||||
effort,
|
||||
remote,
|
||||
@@ -4100,6 +4197,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 {
|
||||
|
||||
@@ -694,6 +694,17 @@ export function registerSystemRoutes(
|
||||
};
|
||||
});
|
||||
|
||||
// ========== OMP ==========
|
||||
|
||||
app.get('/api/omp/status', async () => {
|
||||
const { isOmpAvailable, resolveOmpDir, getOmpCliVersion } = await import('../../utils/omp-cli-resolver.js');
|
||||
return {
|
||||
available: isOmpAvailable(),
|
||||
path: resolveOmpDir(),
|
||||
version: getOmpCliVersion(),
|
||||
};
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// State & Lifecycle (cleanup, lifecycle log, stats)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
+30
-4
@@ -141,6 +141,7 @@ const ALLOWED_ENV_PREFIXES = [
|
||||
// 34-provider-key problem in a new shape, and the answer is the same one.
|
||||
'DSH_',
|
||||
'DEEPSEEK_',
|
||||
'OMP_',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -180,7 +181,7 @@ const safeEnvOverridesSchema = z
|
||||
},
|
||||
{
|
||||
message:
|
||||
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_* keys and CLAUDE_CONFIG_DIR are allowed.',
|
||||
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_*, OMP_* keys and CLAUDE_CONFIG_DIR are allowed.',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -345,6 +346,25 @@ const GrokConfigSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
/**
|
||||
* Schema for OMP CLI-specific configuration.
|
||||
*/
|
||||
const OmpConfigSchema = z
|
||||
.object({
|
||||
model: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9._\-/]+$/)
|
||||
.optional(),
|
||||
resumeSessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9._-]+$/)
|
||||
.optional(),
|
||||
continueSession: z.boolean().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
/**
|
||||
* Schema for DeepSeek Harness (`dsh`)-specific configuration.
|
||||
*
|
||||
@@ -440,7 +460,9 @@ const parentSessionIdSchema = z.string().max(100).optional();
|
||||
|
||||
export const CreateSessionSchema = z.object({
|
||||
workingDir: safePathSchema.optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(),
|
||||
mode: z
|
||||
.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp'])
|
||||
.optional(),
|
||||
name: z.string().max(100).optional(),
|
||||
/** Session that spawned this one — see parentSessionIdSchema. */
|
||||
parentSessionId: parentSessionIdSchema,
|
||||
@@ -458,6 +480,7 @@ export const CreateSessionSchema = z.object({
|
||||
piConfig: PiConfigSchema,
|
||||
grokConfig: GrokConfigSchema,
|
||||
deepSeekConfig: DeepSeekConfigSchema,
|
||||
ompConfig: OmpConfigSchema,
|
||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||
resumeSessionId: z
|
||||
.string()
|
||||
@@ -869,7 +892,9 @@ export const QuickStartSchema = z.object({
|
||||
* a real host dir, so the settings file crosses the bind mount); rejected for
|
||||
* remote cases (the file would be written on the WRONG machine). */
|
||||
modelOverride: z.string().max(50).optional(),
|
||||
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(),
|
||||
mode: z
|
||||
.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp'])
|
||||
.optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
geminiConfig: GeminiConfigSchema,
|
||||
@@ -877,6 +902,7 @@ export const QuickStartSchema = z.object({
|
||||
piConfig: PiConfigSchema,
|
||||
grokConfig: GrokConfigSchema,
|
||||
deepSeekConfig: DeepSeekConfigSchema,
|
||||
ompConfig: OmpConfigSchema,
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
|
||||
effort: effortLevelSchema,
|
||||
@@ -1410,7 +1436,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v);
|
||||
/** Shared field shape for creating/updating a scheduled job. */
|
||||
const CronJobBaseSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']),
|
||||
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']),
|
||||
workingDir: safePathSchema,
|
||||
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
|
||||
promptMode: z.enum(['inline_text', 'prompt_file_path']),
|
||||
|
||||
@@ -1446,6 +1446,7 @@ export class WebServer extends EventEmitter {
|
||||
{ isPiAvailable },
|
||||
{ isGrokAvailable },
|
||||
{ isDeepSeekRunnable, isDeepSeekAvailable },
|
||||
{ isOmpAvailable },
|
||||
{ isCloudflaredAvailable },
|
||||
{ isGitAvailable },
|
||||
] = await Promise.all([
|
||||
@@ -1457,6 +1458,7 @@ export class WebServer extends EventEmitter {
|
||||
import('../utils/pi-cli-resolver.js'),
|
||||
import('../utils/grok-cli-resolver.js'),
|
||||
import('../utils/deepseek-cli-resolver.js'),
|
||||
import('../utils/omp-cli-resolver.js'),
|
||||
import('../utils/cloudflared-resolver.js'),
|
||||
import('../git-clone.js'),
|
||||
]);
|
||||
@@ -1475,6 +1477,7 @@ export class WebServer extends EventEmitter {
|
||||
// profile is offered the fix rather than a greyed-out entry.
|
||||
deepseek: isDeepSeekRunnable(),
|
||||
deepseekBinary: isDeepSeekAvailable(),
|
||||
omp: isOmpAvailable(),
|
||||
cloudflared: isCloudflaredAvailable(),
|
||||
// Not a run mode: the Add Case → Clone tab is an offer this box cannot
|
||||
// keep without git (issue #236), same reasoning as cloudflared above.
|
||||
@@ -2783,6 +2786,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