From 3687bd0fda3ef1e1e8d43361445263514d1cf197 Mon Sep 17 00:00:00 2001 From: Hako Date: Thu, 13 Aug 2026 16:11:11 +0900 Subject: [PATCH] feat(providers): add omo as a discovery and transcript provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit omo writes the same pi-derived JSONL dialect as gjc and Oh My Pi, so session reading and synchronization are reused wholesale through a widened PiTranscriptProvider union; OmoProvider itself is 29 lines. - registry, LLMProvider union, capability matrix, and parseProvider entry - process detection, resume flag, transcript root, and spawn binary mapping - `omo --list-models` prints an aligned text table rather than JSON, so the catalog gets its own six-column parser guarded on the yes/no flag columns - inferOpenOmpSessionIds generalized to every pi transcript root Capabilities are deliberately conservative. There is no omo send runtime yet, and omo's TUI renders "↑↓ navigate • enter select • esc close" with no "Other (type your own)" row, which every parser in tmux-interactive-prompt.service.ts requires, so interactive prompts stay off until omo gets its own parser. Verified against the live process table: a real omo pane classifies as `omo` with the correct pid and cwd. The detection test pins the measured argv, where the extensionless PATH shim is what makes the match work. Co-Authored-By: Claude Opus 5 --- server/modules/providers/README.md | 1 + .../gjc/gjc-session-synchronizer.provider.ts | 7 +- .../list/gjc/gjc-sessions.provider.ts | 5 +- .../providers/list/omo/omo-auth.provider.ts | 19 +++ .../providers/list/omo/omo-mcp.provider.ts | 31 +++++ .../providers/list/omo/omo-models.provider.ts | 92 +++++++++++++++ .../providers/list/omo/omo-skills.provider.ts | 66 +++++++++++ .../providers/list/omo/omo.provider.ts | 29 +++++ .../providers/list/omp/omp-models.provider.ts | 7 +- server/modules/providers/provider.registry.ts | 2 + server/modules/providers/provider.routes.ts | 1 + .../services/external-cli-sessions.service.ts | 110 ++++++++++-------- .../services/provider-capabilities.service.ts | 19 +++ .../services/session-synchronizer.service.ts | 1 + .../external-cli-sessions.service.test.ts | 33 ++++++ server/modules/providers/tests/mcp.test.ts | 11 +- .../providers/tests/omo-provider.test.ts | 74 ++++++++++++ server/shared/types.ts | 2 +- .../chat/hooks/useChatProviderState.ts | 6 +- src/components/chat/types/types.ts | 2 +- .../view/subcomponents/LiveRelayComposer.tsx | 2 +- .../ProviderSelectionEmptyState.tsx | 3 + src/components/llm-logo-provider/OmoLogo.tsx | 34 ++++++ .../llm-logo-provider/SessionProviderLogo.tsx | 5 + src/components/main-content/types/types.ts | 2 +- .../main-content/view/MainContent.tsx | 1 + src/components/provider-auth/types.ts | 4 +- .../tabs/agents-settings/AgentListItem.tsx | 4 + .../agents-settings/AgentsSettingsTab.tsx | 10 +- .../sections/AgentSelectorSection.tsx | 1 + .../sections/content/AccountContent.tsx | 9 ++ .../sidebar/hooks/useExternalCliSessions.ts | 2 +- .../subcomponents/SidebarExternalSection.tsx | 1 + .../view/subcomponents/SidebarNewSession.tsx | 3 +- src/types/app.ts | 4 +- 35 files changed, 536 insertions(+), 67 deletions(-) create mode 100644 server/modules/providers/list/omo/omo-auth.provider.ts create mode 100644 server/modules/providers/list/omo/omo-mcp.provider.ts create mode 100644 server/modules/providers/list/omo/omo-models.provider.ts create mode 100644 server/modules/providers/list/omo/omo-skills.provider.ts create mode 100644 server/modules/providers/list/omo/omo.provider.ts create mode 100644 server/modules/providers/tests/omo-provider.test.ts create mode 100644 src/components/llm-logo-provider/OmoLogo.tsx diff --git a/server/modules/providers/README.md b/server/modules/providers/README.md index baa8f43..f286d8c 100644 --- a/server/modules/providers/README.md +++ b/server/modules/providers/README.md @@ -38,6 +38,7 @@ Current provider ids accepted by the registry and `parseProvider` are: - `opencode` - `gjc` - `omp` +- `omo` Those ids are mirrored in backend unions and frontend provider constants. If adding a new provider, update every place that hardcodes this list. diff --git a/server/modules/providers/list/gjc/gjc-session-synchronizer.provider.ts b/server/modules/providers/list/gjc/gjc-session-synchronizer.provider.ts index 524e141..34fa1ba 100644 --- a/server/modules/providers/list/gjc/gjc-session-synchronizer.provider.ts +++ b/server/modules/providers/list/gjc/gjc-session-synchronizer.provider.ts @@ -14,7 +14,12 @@ import { import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js'; import type { AnyRecord } from '@/shared/types.js'; -type PiTranscriptProvider = 'gjc' | 'omp'; +/** + * CLIs that write the pi-derived transcript dialect: the same JSONL envelope + * (`message`, `model_change`, `thinking_level_change`, `custom`), so one reader + * and one synchronizer serve all of them. + */ +export type PiTranscriptProvider = 'gjc' | 'omp' | 'omo'; export type PiSessionSynchronizerOptions = { provider?: PiTranscriptProvider; diff --git a/server/modules/providers/list/gjc/gjc-sessions.provider.ts b/server/modules/providers/list/gjc/gjc-sessions.provider.ts index f9d9b85..c76eebd 100644 --- a/server/modules/providers/list/gjc/gjc-sessions.provider.ts +++ b/server/modules/providers/list/gjc/gjc-sessions.provider.ts @@ -1,6 +1,7 @@ import fsSync from 'node:fs'; import { sessionsDb } from '@/modules/database/index.js'; +import type { PiTranscriptProvider } from '@/modules/providers/list/gjc/gjc-session-synchronizer.provider.js'; import type { IProviderSessions } from '@/shared/interfaces.js'; import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js'; import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js'; @@ -188,7 +189,7 @@ function normalizeGjcToolInput(toolName: string, value: unknown): unknown { * its own intermediate record with a unique id so multi-part turns never collide. */ async function streamPiSessionMessages( - provider: 'gjc' | 'omp', + provider: PiTranscriptProvider, sessionId: string, onMessage: (message: AnyRecord) => void, ): Promise { @@ -337,7 +338,7 @@ async function streamPiSessionMessages( } export class GjcSessionsProvider implements IProviderSessions { - constructor(private readonly provider: 'gjc' | 'omp' = 'gjc') {} + constructor(private readonly provider: PiTranscriptProvider = 'gjc') {} /** * Normalizes one flattened gjc content-part record into the shared envelope. */ diff --git a/server/modules/providers/list/omo/omo-auth.provider.ts b/server/modules/providers/list/omo/omo-auth.provider.ts new file mode 100644 index 0000000..fadd7ce --- /dev/null +++ b/server/modules/providers/list/omo/omo-auth.provider.ts @@ -0,0 +1,19 @@ +import spawn from 'cross-spawn'; + +import type { IProviderAuth } from '@/shared/interfaces.js'; +import type { ProviderAuthStatus } from '@/shared/types.js'; + +export class OmoProviderAuth implements IProviderAuth { + async getStatus(): Promise { + const result = spawn.sync('omo', ['--version'], { stdio: 'ignore', timeout: 5_000 }); + const installed = !result.error && result.status === 0; + return { + installed, + provider: 'omo', + authenticated: installed, + email: installed ? 'CLI managed' : null, + method: installed ? 'cli' : null, + error: installed ? undefined : 'omo CLI is not installed', + }; + } +} diff --git a/server/modules/providers/list/omo/omo-mcp.provider.ts b/server/modules/providers/list/omo/omo-mcp.provider.ts new file mode 100644 index 0000000..7340d16 --- /dev/null +++ b/server/modules/providers/list/omo/omo-mcp.provider.ts @@ -0,0 +1,31 @@ +import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js'; +import type { ProviderMcpServer } from '@/shared/types.js'; +import { AppError } from '@/shared/utils.js'; + +export class OmoMcpProvider extends McpProvider { + constructor() { + super('omo', ['user', 'project'], ['stdio', 'http']); + } + + protected async readScopedServers(): Promise> { + return {}; + } + + protected async writeScopedServers(): Promise { + throw new AppError('omo MCP configuration is not supported by ChatMux.', { + code: 'MCP_WRITE_UNSUPPORTED', + statusCode: 400, + }); + } + + protected buildServerConfig(): Record { + throw new AppError('omo MCP configuration is not supported by ChatMux.', { + code: 'MCP_WRITE_UNSUPPORTED', + statusCode: 400, + }); + } + + protected normalizeServerConfig(): ProviderMcpServer | null { + return null; + } +} diff --git a/server/modules/providers/list/omo/omo-models.provider.ts b/server/modules/providers/list/omo/omo-models.provider.ts new file mode 100644 index 0000000..81c7bae --- /dev/null +++ b/server/modules/providers/list/omo/omo-models.provider.ts @@ -0,0 +1,92 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +import { + readLastTranscriptActiveModel, +} from '@/modules/providers/list/omp/omp-models.provider.js'; +import { sessionsDb } from '@/modules/database/index.js'; +import type { IProviderModels } from '@/shared/interfaces.js'; +import type { + ProviderChangeActiveModelInput, + ProviderCurrentActiveModel, + ProviderModelOption, + ProviderModelsDefinition, + ProviderSessionActiveModelChange, +} from '@/shared/types.js'; +import { + buildDefaultProviderCurrentActiveModel, + writeProviderSessionActiveModelChange, +} from '@/shared/utils.js'; + +const execFileAsync = promisify(execFile); + +const OMO_FALLBACK_MODELS: ProviderModelsDefinition = { + OPTIONS: [{ value: 'default', label: 'Current CLI model' }], + DEFAULT: 'default', +}; + +/** + * `omo --list-models` prints an aligned text table, not JSON: + * + * provider model context max-out thinking images + * alibaba-token-plan deepseek-v3.2 131.1K 65.5K yes no + * + * Every column is whitespace-free, so a row is exactly six tokens. Requiring + * that shape plus yes/no flags rejects the header and any startup chatter the + * CLI writes before the table. + */ +export function parseOmoModelCatalog(raw: string): ProviderModelsDefinition { + const options: ProviderModelOption[] = []; + const seen = new Set(); + for (const line of raw.split(/\r?\n/)) { + const columns = line.trim().split(/\s+/); + if (columns.length !== 6) continue; + const [provider, model, context, , thinking, images] = columns; + if (!/^(?:yes|no)$/i.test(thinking) || !/^(?:yes|no)$/i.test(images)) continue; + const selector = `${provider}/${model}`; + if (seen.has(selector)) continue; + seen.add(selector); + options.push({ + value: selector, + label: model, + description: `${context} context · ${provider}`, + }); + } + return options.length > 0 + ? { OPTIONS: [...OMO_FALLBACK_MODELS.OPTIONS, ...options], DEFAULT: OMO_FALLBACK_MODELS.DEFAULT } + : OMO_FALLBACK_MODELS; +} + +async function loadOmoModelCatalog(): Promise { + try { + const { stdout } = await execFileAsync( + 'omo', + ['--list-models'], + { encoding: 'utf8', timeout: 30_000, maxBuffer: 8 * 1024 * 1024 }, + ); + return parseOmoModelCatalog(stdout); + } catch { + return OMO_FALLBACK_MODELS; + } +} + +export class OmoProviderModels implements IProviderModels { + async getSupportedModels(): Promise { + return loadOmoModelCatalog(); + } + + async getCurrentActiveModel(sessionId?: string): Promise { + const row = sessionId ? sessionsDb.getSessionById(sessionId) : null; + if (row?.jsonl_path) { + const activeModel = await readLastTranscriptActiveModel(row.jsonl_path).catch(() => null); + if (activeModel) return activeModel; + } + return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels()); + } + + async changeActiveModel( + input: ProviderChangeActiveModelInput, + ): Promise { + return writeProviderSessionActiveModelChange('omo', input); + } +} diff --git a/server/modules/providers/list/omo/omo-skills.provider.ts b/server/modules/providers/list/omo/omo-skills.provider.ts new file mode 100644 index 0000000..e50bbff --- /dev/null +++ b/server/modules/providers/list/omo/omo-skills.provider.ts @@ -0,0 +1,66 @@ +import os from 'node:os'; +import path from 'node:path'; + +import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js'; +import type { ProviderSkillSource } from '@/shared/types.js'; +import { addUniqueProviderSkillSource, findTopmostGitRoot } from '@/shared/utils.js'; + +const PROJECT_SKILL_DIRS = [ + ['.omo', 'skills'], + ['.agent', 'skills'], + ['.agents', 'skills'], + ['.codex', 'skills'], + ['.claude', 'skills'], +] as const; + +const USER_SKILL_DIRS = [ + ['.omo', 'agent', 'skills'], + ['.agent', 'skills'], + ['.omo', 'agent', 'managed-skills'], + ['.agents', 'skills'], + ['.codex', 'skills'], + ['.claude', 'skills'], +] as const; + +export class OmoSkillsProvider extends SkillsProvider { + constructor() { + super('omo'); + } + + protected async getSkillSources(workspacePath: string): Promise { + const sources: ProviderSkillSource[] = []; + const seenRootDirs = new Set(); + const repoRoot = await findTopmostGitRoot(workspacePath); + const projectRoots = repoRoot && path.resolve(repoRoot) !== path.resolve(workspacePath) + ? [workspacePath, repoRoot] + : [workspacePath]; + + for (const projectRoot of projectRoots) { + for (const segments of PROJECT_SKILL_DIRS) { + addUniqueProviderSkillSource(sources, seenRootDirs, { + scope: 'project', + rootDir: path.join(projectRoot, ...segments), + commandPrefix: '/skill:', + }); + } + } + + for (const segments of USER_SKILL_DIRS) { + addUniqueProviderSkillSource(sources, seenRootDirs, { + scope: 'user', + rootDir: path.join(os.homedir(), ...segments), + commandPrefix: '/skill:', + }); + } + + return sources; + } + + protected async getGlobalSkillSource(): Promise { + return { + scope: 'user', + rootDir: path.join(os.homedir(), '.omo', 'agent', 'skills'), + commandPrefix: '/skill:', + }; + } +} diff --git a/server/modules/providers/list/omo/omo.provider.ts b/server/modules/providers/list/omo/omo.provider.ts new file mode 100644 index 0000000..84e5369 --- /dev/null +++ b/server/modules/providers/list/omo/omo.provider.ts @@ -0,0 +1,29 @@ +import { OmoProviderAuth } from '@/modules/providers/list/omo/omo-auth.provider.js'; +import { OmoMcpProvider } from '@/modules/providers/list/omo/omo-mcp.provider.js'; +import { OmoProviderModels } from '@/modules/providers/list/omo/omo-models.provider.js'; +import { OmoSkillsProvider } from '@/modules/providers/list/omo/omo-skills.provider.js'; +import { GjcSessionSynchronizer } from '@/modules/providers/list/gjc/gjc-session-synchronizer.provider.js'; +import { GjcSessionsProvider } from '@/modules/providers/list/gjc/gjc-sessions.provider.js'; +import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js'; +import type { + IProviderAuth, + IProviderModels, + IProviderSessionSynchronizer, + IProviderSkills, + IProviderSessions, +} from '@/shared/interfaces.js'; + +export class OmoProvider extends AbstractProvider { + readonly models: IProviderModels = new OmoProviderModels(); + readonly mcp = new OmoMcpProvider(); + readonly auth: IProviderAuth = new OmoProviderAuth(); + readonly skills: IProviderSkills = new OmoSkillsProvider(); + readonly sessions: IProviderSessions = new GjcSessionsProvider('omo'); + readonly sessionSynchronizer: IProviderSessionSynchronizer = new GjcSessionSynchronizer({ + provider: 'omo', + }); + + constructor() { + super('omo'); + } +} diff --git a/server/modules/providers/list/omp/omp-models.provider.ts b/server/modules/providers/list/omp/omp-models.provider.ts index 8e7d914..3c6cdfc 100644 --- a/server/modules/providers/list/omp/omp-models.provider.ts +++ b/server/modules/providers/list/omp/omp-models.provider.ts @@ -102,7 +102,12 @@ export function parseOmpTranscriptActiveModelLine( } } -async function readLastTranscriptActiveModel( +/** + * Shared by every pi-derived CLI (omp, gjc, omo): they all record + * `model_change`, `thinking_level_change`, and `configured_model_chain` with + * the same shape, so the last active model is read the same way. + */ +export async function readLastTranscriptActiveModel( filePath: string, ): Promise { let model: string | null = null; diff --git a/server/modules/providers/provider.registry.ts b/server/modules/providers/provider.registry.ts index 1c0d8e2..76f00ed 100644 --- a/server/modules/providers/provider.registry.ts +++ b/server/modules/providers/provider.registry.ts @@ -3,6 +3,7 @@ import { CodexProvider } from '@/modules/providers/list/codex/codex.provider.js' import { CursorProvider } from '@/modules/providers/list/cursor/cursor.provider.js'; import { GjcProvider } from '@/modules/providers/list/gjc/gjc.provider.js'; import { OpenCodeProvider } from '@/modules/providers/list/opencode/opencode.provider.js'; +import { OmoProvider } from '@/modules/providers/list/omo/omo.provider.js'; import { OmpProvider } from '@/modules/providers/list/omp/omp.provider.js'; import type { IProvider } from '@/shared/interfaces.js'; import type { LLMProvider } from '@/shared/types.js'; @@ -15,6 +16,7 @@ const providers: Record = { opencode: new OpenCodeProvider(), gjc: new GjcProvider(), omp: new OmpProvider(), + omo: new OmoProvider(), }; /** diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index c02166d..bba91a8 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -597,6 +597,7 @@ const parseProvider = (value: unknown): LLMProvider => { || normalized === 'opencode' || normalized === 'gjc' || normalized === 'omp' + || normalized === 'omo' ) { return normalized; } diff --git a/server/modules/providers/services/external-cli-sessions.service.ts b/server/modules/providers/services/external-cli-sessions.service.ts index fcc6585..ae7a4e5 100644 --- a/server/modules/providers/services/external-cli-sessions.service.ts +++ b/server/modules/providers/services/external-cli-sessions.service.ts @@ -42,12 +42,13 @@ const CODEX_RESUME_THREAD_RE = /(?:^|\s)resume\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a- const CLAUDE_RESUME_SESSION_RE = /(?:^|\s)--resume(?:=|\s+)([0-9a-f]{8}-[0-9a-f-]{27,})(?=\s|$)/i; const CURSOR_RESUME_SESSION_RE = /(?:^|\s)(?:--resume|resume)(?:=|\s+)([A-Za-z0-9_-]{8,128})(?=\s|$)/; const OPENCODE_SESSION_RE = /(?:^|\s)--session(?:=|\s+)([A-Za-z0-9_-]{8,128})(?=\s|$)/; -const OMP_RESUME_SESSION_RE = /(?:^|\s)(?:--resume|-r)(?:=|\s+)([A-Za-z0-9_-]{8,128})(?=\s|$)/; +// Oh My Pi and omo are both pi-derived and accept the identical `--resume|-r` form. +const PI_RESUME_SESSION_RE = /(?:^|\s)(?:--resume|-r)(?:=|\s+)([A-Za-z0-9_-]{8,128})(?=\s|$)/; const TRANSCRIPT_FILE_SESSION_ID_RE = /_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; const CODEX_ROLLOUT_FILE_RE = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; const MAX_RUNTIME_DESCRIPTORS = 2_048; -export type ExternalLocalCliKind = 'claude' | 'codex' | 'cursor' | 'opencode' | 'omp'; +export type ExternalLocalCliKind = 'claude' | 'codex' | 'cursor' | 'opencode' | 'omp' | 'omo'; export type ExternalCliKind = ExternalLocalCliKind | 'ssh' | 'shell'; export type ExternalCliSession = { tmuxName: string; @@ -201,7 +202,7 @@ export function extractExternalResumeSessionId( if (kind === 'codex') return extractCodexResumeThreadId(processArgs); if (kind === 'cursor') return processArgs.match(CURSOR_RESUME_SESSION_RE)?.[1] ?? null; if (kind === 'opencode') return processArgs.match(OPENCODE_SESSION_RE)?.[1] ?? null; - if (kind === 'omp') return processArgs.match(OMP_RESUME_SESSION_RE)?.[1] ?? null; + if (kind === 'omp' || kind === 'omo') return processArgs.match(PI_RESUME_SESSION_RE)?.[1] ?? null; return null; } @@ -457,7 +458,7 @@ export function parseExternalPanes(output: string): ExternalPane[] { command: pane.command, ...(pane.codexThreadId ? { codexThreadId: pane.codexThreadId } : {}), ...(pane.cwd ? { cwd: pane.cwd } : {}), - ...(pane.taggedKind && ['claude', 'codex', 'cursor', 'opencode', 'omp'].includes(pane.taggedKind) + ...(pane.taggedKind && ['claude', 'codex', 'cursor', 'opencode', 'omp', 'omo'].includes(pane.taggedKind) ? { taggedKind: pane.taggedKind as ExternalLocalCliKind } : {}), ...(pane.taggedSessionId ? { taggedSessionId: pane.taggedSessionId } : {}), @@ -500,6 +501,9 @@ function processCliKind(proc: Pick): External if (isCursorCliProcess(proc)) return 'cursor'; if (executable('opencode')) return 'opencode'; if (executable('omp')) return 'omp'; + // omo is a node script reached through a PATH shim, so argv carries + // ` /…/bin/omo` (no extension) and `executable` matches that token. + if (executable('omo')) return 'omo'; if (executable('ssh')) return 'ssh'; return null; } @@ -540,7 +544,7 @@ export function classifyExternalSessions(args: { children.set(proc.ppid, siblings); } - const priority: Array> = ['claude', 'codex', 'cursor', 'opencode', 'omp', 'ssh']; + const priority: Array> = ['claude', 'codex', 'cursor', 'opencode', 'omp', 'omo', 'ssh']; const result: ExternalCliSession[] = []; for (const pane of args.panes) { @@ -571,14 +575,17 @@ export function classifyExternalSessions(args: { } if (subtreeKinds.some(({ kind }) => kind === 'gjc')) continue; - // Bun-launched Oh My Pi keeps the shell as tmux's pane PID and the `omp` - // executable as its direct child, while pane_current_command is only `bun`. - // Accept that exact shell-owned wrapper shape; an OMP worker nested under - // an app process must remain an unclassified terminal row. - const directShellOmp = isInteractiveShellProcess(procByPid.get(pane.pid)) - && subtreeKinds.some(({ kind, proc }) => kind === 'omp' && proc.ppid === pane.pid); - if (directShellOmp) { - kinds.add('omp'); + // Bun-launched Oh My Pi and node-launched omo keep the shell as tmux's pane + // PID and the CLI as its direct child, while pane_current_command reads only + // `bun` or `node`. Accept that exact shell-owned wrapper shape; a worker + // nested under an app process must remain an unclassified terminal row. + const paneIsInteractiveShell = isInteractiveShellProcess(procByPid.get(pane.pid)); + for (const wrappedKind of ['omp', 'omo'] as const) { + const directShellPi = paneIsInteractiveShell + && subtreeKinds.some(({ kind, proc }) => kind === wrappedKind && proc.ppid === pane.pid); + if (directShellPi) { + kinds.add(wrappedKind); + } } // The documented Cursor `agent` launcher execs a Node process, so tmux may // report `agent`, `node`, or `MainThread` while the shell-owned child argv @@ -986,38 +993,48 @@ export function extractContainedTranscriptSessionId( * Resolve that file through /proc so an already-running, untagged tmux pane can * attach to structured history without relying on filesystem creation times. */ -async function inferOpenOmpSessionIds( +const PI_TRANSCRIPT_HOME_DIRS = { + omp: '.omp', + omo: '.omo', +} as const; + +async function inferOpenPiSessionIds( sessions: ExternalCliSession[], ): Promise> { - const targets = sessions.filter((session) => ( - session.kind === 'omp' - && session.agentPid !== undefined - )); - if (targets.length === 0) return new Map(); - - const sessionsRoot = await realpath(join(homedir(), '.omp', 'agent', 'sessions')).catch(() => null); - if (!sessionsRoot) return new Map(); - const resolved = new Map(); - await Promise.all(targets.map(async (session) => { - const fdRoot = `/proc/${session.agentPid}/fd`; - const descriptors = await readdir(fdRoot).catch(() => []); - const transcriptById = new Map(); - await Promise.all(descriptors.slice(0, MAX_RUNTIME_DESCRIPTORS).map(async (descriptor) => { - const transcriptPath = await realpath(join(fdRoot, descriptor)).catch(() => null); - if (!transcriptPath) return; - const sessionId = extractContainedTranscriptSessionId(sessionsRoot, transcriptPath); - if (sessionId) transcriptById.set(sessionId, transcriptPath); + const kinds = Object.keys(PI_TRANSCRIPT_HOME_DIRS) as Array; + await Promise.all(kinds.map(async (kind) => { + const targets = sessions.filter((session) => ( + session.kind === kind + && session.agentPid !== undefined + )); + if (targets.length === 0) return; + + const sessionsRoot = await realpath( + join(homedir(), PI_TRANSCRIPT_HOME_DIRS[kind], 'agent', 'sessions'), + ).catch(() => null); + if (!sessionsRoot) return; + + await Promise.all(targets.map(async (session) => { + const fdRoot = `/proc/${session.agentPid}/fd`; + const descriptors = await readdir(fdRoot).catch(() => []); + const transcriptById = new Map(); + await Promise.all(descriptors.slice(0, MAX_RUNTIME_DESCRIPTORS).map(async (descriptor) => { + const transcriptPath = await realpath(join(fdRoot, descriptor)).catch(() => null); + if (!transcriptPath) return; + const sessionId = extractContainedTranscriptSessionId(sessionsRoot, transcriptPath); + if (sessionId) transcriptById.set(sessionId, transcriptPath); + })); + if (transcriptById.size !== 1) return; + + const [[sessionId, transcriptPath]] = [...transcriptById]; + if (!sessionsDb.getSessionByProviderSessionId(kind, sessionId)) { + await providerRegistry.resolveProvider(kind).sessionSynchronizer + .synchronizeFile(transcriptPath) + .catch(() => undefined); + } + resolved.set(tmuxPaneIdentityKey(session.tmux), sessionId); })); - if (transcriptById.size !== 1) return; - - const [[sessionId, transcriptPath]] = [...transcriptById]; - if (!sessionsDb.getSessionByProviderSessionId('omp', sessionId)) { - await providerRegistry.resolveProvider('omp').sessionSynchronizer - .synchronizeFile(transcriptPath) - .catch(() => undefined); - } - resolved.set(tmuxPaneIdentityKey(session.tmux), sessionId); })); return resolved; } @@ -1052,11 +1069,11 @@ async function inferIndexedProviderSessionIds( attemptableTargetKeys: ReadonlySet, ): Promise> { const unresolved = sessions.filter((session): session is ExternalCliSession & { - kind: 'cursor' | 'opencode' | 'omp'; + kind: 'cursor' | 'opencode' | 'omp' | 'omo'; cwd: string; startedAtMs: number; } => ( - (session.kind === 'cursor' || session.kind === 'opencode' || session.kind === 'omp') + (session.kind === 'cursor' || session.kind === 'opencode' || session.kind === 'omp' || session.kind === 'omo') && !session.providerSessionId && typeof session.cwd === 'string' && typeof session.startedAtMs === 'number' @@ -1156,7 +1173,7 @@ async function inferExternalProviderSessionIds(args: { panes: args.panes, procs: args.procs, }), - inferOpenOmpSessionIds(safeSessions), + inferOpenPiSessionIds(safeSessions), args.attemptableSessions.some((session) => session.kind === 'codex') ? inferFreshCodexThreadIds({ sessions: args.attemptableSessions, @@ -1185,7 +1202,7 @@ async function inferExternalProviderSessionIds(args: { authoritativeTargetKeys, ); const inferredIndexed = args.attemptableSessions.some((session) => ( - session.kind === 'cursor' || session.kind === 'opencode' || session.kind === 'omp' + session.kind === 'cursor' || session.kind === 'opencode' || session.kind === 'omp' || session.kind === 'omo' )) ? await inferIndexedProviderSessionIds(withDirectIds, attemptableTargetKeys) : new Map(); @@ -1237,6 +1254,7 @@ const EXTERNAL_CLI_COMMANDS: Record = { cursor: CURSOR_CLI_COMMAND_CANDIDATES, opencode: ['opencode'], omp: ['omp'], + omo: ['omo'], }; type ExternalCliExecutableResolverOptions = { @@ -1448,7 +1466,7 @@ async function discoverExternalCliSessions( command: pane.command, ...(pane.codexThreadId ? { codexThreadId: pane.codexThreadId } : {}), ...(pane.cwd ? { cwd: pane.cwd } : {}), - ...(pane.taggedKind && ['claude', 'codex', 'cursor', 'opencode', 'omp'].includes(pane.taggedKind) + ...(pane.taggedKind && ['claude', 'codex', 'cursor', 'opencode', 'omp', 'omo'].includes(pane.taggedKind) ? { taggedKind: pane.taggedKind as ExternalLocalCliKind } : {}), ...(pane.taggedSessionId ? { taggedSessionId: pane.taggedSessionId } : {}), diff --git a/server/modules/providers/services/provider-capabilities.service.ts b/server/modules/providers/services/provider-capabilities.service.ts index c29e060..89b60c6 100644 --- a/server/modules/providers/services/provider-capabilities.service.ts +++ b/server/modules/providers/services/provider-capabilities.service.ts @@ -109,6 +109,25 @@ const PROVIDER_CAPABILITIES: Record = { // (`server/omp-cli.ts:48-49`), but this matrix still hides the effort control. supportsEffort: false, }, + omo: { + provider: 'omo', + permissionModes: ['default'], + defaultPermissionMode: 'default', + // omo is wired for discovery and transcript reading only. There is no omo + // send runtime (no `server/omo-cli.ts`), so nothing forwards attachments or + // can cancel an in-flight run yet. + supportsImages: false, + supportsAbort: false, + // omo's TUI renders "↑↓ navigate • enter select • esc close" and has no + // "Other (type your own)" row, so every parser in + // tmux-interactive-prompt.service.ts rejects it. Interactive prompts stay + // off until omo gets its own parser. + supportsPermissionRequests: false, + supportsTokenUsage: false, + // `omo --list-models` reports thinking as a yes/no column and never + // enumerates the levels, so no effort values can be offered. + supportsEffort: false, + }, }; /** diff --git a/server/modules/providers/services/session-synchronizer.service.ts b/server/modules/providers/services/session-synchronizer.service.ts index 6fc5576..d13ab7c 100644 --- a/server/modules/providers/services/session-synchronizer.service.ts +++ b/server/modules/providers/services/session-synchronizer.service.ts @@ -24,6 +24,7 @@ export const sessionSynchronizerService = { opencode: 0, gjc: 0, omp: 0, + omo: 0, }; const failures: string[] = []; diff --git a/server/modules/providers/tests/external-cli-sessions.service.test.ts b/server/modules/providers/tests/external-cli-sessions.service.test.ts index f9775d7..3f6531d 100644 --- a/server/modules/providers/tests/external-cli-sessions.service.test.ts +++ b/server/modules/providers/tests/external-cli-sessions.service.test.ts @@ -817,6 +817,39 @@ test('classifyExternalSessions recognizes Cursor, OpenCode, and Oh My Pi process ]); }); +test('classifyExternalSessions recognizes the node-launched omo shell wrapper', () => { + const result = classifyExternalSessions({ + panes: [{ + name: 'omo-work', + tmux: tmux('$1100', '@1100', '%1100'), + pid: 1100, + // tmux reports the interpreter, never the CLI: the measured comm is `node`. + command: 'node', + cwd: '/omo', + }], + procs: [ + { pid: 1100, ppid: 1, comm: 'zsh', args: '-zsh' }, + { + pid: 1101, + ppid: 1100, + comm: 'node', + // Measured argv. The PATH shim carries no extension, so `/…/bin/omo` is + // the argv token detection matches; a `.js` entry would not match. + args: 'node /home/user/.nvm/versions/node/v24.18.0/bin/omo --resume 019ff9a1-dc29-73bc-89a0-2435c969dc1b', + }, + ], + }); + + assert.deepEqual(result, [{ + tmuxName: 'omo-work', + tmux: tmux('$1100', '@1100', '%1100'), + kind: 'omo', + providerSessionId: '019ff9a1-dc29-73bc-89a0-2435c969dc1b', + cwd: '/omo', + agentPid: 1101, + }]); +}); + test('classifyExternalSessions recognizes the official Cursor agent launcher shape', () => { const result = classifyExternalSessions({ panes: [{ diff --git a/server/modules/providers/tests/mcp.test.ts b/server/modules/providers/tests/mcp.test.ts index 1075fbc..9dcf9e4 100644 --- a/server/modules/providers/tests/mcp.test.ts +++ b/server/modules/providers/tests/mcp.test.ts @@ -313,10 +313,11 @@ test('providerMcpService global adder writes to all providers and rejects unsupp workspacePath, }); - // GJC and OMP expose read-only MCP stubs. Both must report graceful - // per-provider failures while writable providers receive the server. - assert.equal(globalResult.length, 6); - for (const provider of ['gjc', 'omp'] as const) { + // GJC, OMP, and omo expose read-only MCP stubs. Each must report a graceful + // per-provider failure while writable providers receive the server. + const readOnlyProviders = ['gjc', 'omp', 'omo'] as const; + assert.equal(globalResult.length, 7); + for (const provider of readOnlyProviders) { const entry = globalResult.find((result) => result.provider === provider); assert.ok(entry); assert.equal(entry.created, false); @@ -324,7 +325,7 @@ test('providerMcpService global adder writes to all providers and rejects unsupp } assert.ok( globalResult - .filter((entry) => entry.provider !== 'gjc' && entry.provider !== 'omp') + .filter((entry) => !readOnlyProviders.includes(entry.provider as typeof readOnlyProviders[number])) .every((entry) => entry.created === true), ); diff --git a/server/modules/providers/tests/omo-provider.test.ts b/server/modules/providers/tests/omo-provider.test.ts new file mode 100644 index 0000000..3ceae91 --- /dev/null +++ b/server/modules/providers/tests/omo-provider.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { parseOmoModelCatalog } from '@/modules/providers/list/omo/omo-models.provider.js'; +import { OmoSkillsProvider } from '@/modules/providers/list/omo/omo-skills.provider.js'; + +// Verbatim `omo --list-models` output: an aligned table, preceded by the +// startup notice the CLI writes before the table. +const LIST_MODELS_OUTPUT = [ + "config-watch user config discovery requires reload { userConfigCreationDiscovery: 'reload_required' }", + 'provider model context max-out thinking images', + 'alibaba-token-plan deepseek-v3.2 131.1K 65.5K yes no ', + 'alibaba-token-plan kimi-k2.5 262.1K 98.3K yes yes ', + 'alibaba-token-plan deepseek-v3.2 131.1K 65.5K yes no ', + '', +].join('\n'); + +test('parseOmoModelCatalog reads the table, drops the header and startup noise, and dedupes', () => { + const catalog = parseOmoModelCatalog(LIST_MODELS_OUTPUT); + + assert.equal(catalog.DEFAULT, 'default'); + assert.deepEqual(catalog.OPTIONS, [ + { value: 'default', label: 'Current CLI model' }, + { + value: 'alibaba-token-plan/deepseek-v3.2', + label: 'deepseek-v3.2', + description: '131.1K context · alibaba-token-plan', + }, + { + value: 'alibaba-token-plan/kimi-k2.5', + label: 'kimi-k2.5', + description: '262.1K context · alibaba-token-plan', + }, + ]); +}); + +test('parseOmoModelCatalog falls back when no row survives the shape guard', () => { + for (const raw of [ + '', + 'provider model context max-out thinking images', + "config-watch user config discovery requires reload { userConfigCreationDiscovery: 'reload_required' }", + 'alibaba-token-plan deepseek-v3.2 131.1K 65.5K maybe no', + ]) { + const catalog = parseOmoModelCatalog(raw); + assert.deepEqual(catalog.OPTIONS, [{ value: 'default', label: 'Current CLI model' }], raw.slice(0, 40)); + assert.equal(catalog.DEFAULT, 'default'); + } +}); + +class InspectableOmoSkillsProvider extends OmoSkillsProvider { + sources(workspacePath: string) { + return this.getSkillSources(workspacePath); + } +} + +test('omo skill sources use omo-owned roots and the native /skill: prefix', async () => { + const workspacePath = '/tmp/chatmux-omo-skills-workspace'; + const sources = await new InspectableOmoSkillsProvider().sources(workspacePath); + + assert.ok(sources.some((source) => ( + source.rootDir === path.join(workspacePath, '.omo', 'skills') + && source.scope === 'project' + && source.commandPrefix === '/skill:' + ))); + assert.ok(sources.some((source) => ( + source.rootDir === path.join(os.homedir(), '.omo', 'agent', 'skills') + && source.scope === 'user' + && source.commandPrefix === '/skill:' + ))); + assert.ok(sources.every((source) => source.commandPrefix === '/skill:')); + assert.ok(sources.every((source) => !source.rootDir.includes('/.omp/'))); +}); diff --git a/server/shared/types.ts b/server/shared/types.ts index 8f95a3f..ee8f6ee 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -65,7 +65,7 @@ export type AuthenticatedWebSocketRequest = IncomingMessage & { * Use this as the source of truth whenever a function or payload needs to identify * a specific LLM integration. */ -export type LLMProvider = 'claude' | 'codex' | 'cursor' | 'opencode' | 'gjc' | 'omp'; +export type LLMProvider = 'claude' | 'codex' | 'cursor' | 'opencode' | 'gjc' | 'omp' | 'omo'; /** * One selectable model row in a provider model catalog. diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index 2579bd6..2ee14cc 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -23,9 +23,10 @@ const FALLBACK_DEFAULT_MODEL: Record = { opencode: 'anthropic/claude-sonnet-4-5', gjc: 'default', omp: 'default', + omo: 'default', }; -const PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'opencode', 'gjc', 'omp']; +const PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'opencode', 'gjc', 'omp', 'omo']; const readStoredProvider = (): LLMProvider => { const storedProvider = localStorage.getItem('selected-provider'); @@ -47,6 +48,7 @@ const FALLBACK_PERMISSION_MODES: Record = { opencode: ['default', 'acceptEdits', 'bypassPermissions', 'plan'], gjc: ['default'], omp: ['default'], + omo: ['default'], }; type ProviderCapabilities = { @@ -372,6 +374,8 @@ export function useChatProviderState({ selectedSession, selectedProject: _select opencode: opencodeModel, gjc: 'default', omp: ompModel, + // omo has no composer-side model picker yet; the CLI keeps its own selection. + omo: 'default', }), [claudeModel, cursorModel, codexModel, opencodeModel, ompModel]); useEffect(() => { diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index a10ab3a..78da491 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -129,7 +129,7 @@ export interface ChatInterfaceProps { liveSessionModel: string | null; liveSessionEffort: string | null; liveSessionName: string | null; - liveSessionKind: 'gjc' | 'codex' | 'claude' | 'cursor' | 'opencode' | 'omp' | null; + liveSessionKind: 'gjc' | 'codex' | 'claude' | 'cursor' | 'opencode' | 'omp' | 'omo' | null; /** True while the viewed live/external session is running a turn. */ liveSessionProcessing?: boolean; ws: WebSocket | null; diff --git a/src/components/chat/view/subcomponents/LiveRelayComposer.tsx b/src/components/chat/view/subcomponents/LiveRelayComposer.tsx index 340ea47..3eccfa4 100644 --- a/src/components/chat/view/subcomponents/LiveRelayComposer.tsx +++ b/src/components/chat/view/subcomponents/LiveRelayComposer.tsx @@ -91,7 +91,7 @@ export default function LiveRelayComposer({ effort?: string | null; sessionName?: string | null; workspacePath?: string | null; - relayKind?: 'gjc' | 'codex' | 'claude' | 'cursor' | 'opencode' | 'omp'; + relayKind?: 'gjc' | 'codex' | 'claude' | 'cursor' | 'opencode' | 'omp' | 'omo'; /** True while the target session is running a turn — enables the stop control. */ isProcessing?: boolean; transcriptSessionId?: string | null; diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx index 3689c81..b03683b 100644 --- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx +++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx @@ -321,6 +321,9 @@ export default function ProviderSelectionEmptyState({ defaultValue: "Ready with Gajae Code", }), omp: `Ready with Oh My Pi ${ompModel}`, + omo: t("providerSelection.readyPrompt.omo", { + defaultValue: "Ready with omo", + }), }[provider] }

diff --git a/src/components/llm-logo-provider/OmoLogo.tsx b/src/components/llm-logo-provider/OmoLogo.tsx new file mode 100644 index 0000000..bdc0f70 --- /dev/null +++ b/src/components/llm-logo-provider/OmoLogo.tsx @@ -0,0 +1,34 @@ +import { useId } from 'react'; + +type OmoLogoProps = { + className?: string; +}; + +/** + * Placeholder mark: the omo package ships no brand asset, so this is a plain + * geometric "o" rather than an official logo. Swap it when one exists. + */ +const OmoLogo = ({ className = 'w-5 h-5' }: OmoLogoProps) => { + const gradientId = useId(); + + return ( + + + + + + + + + + + ); +}; + +export default OmoLogo; diff --git a/src/components/llm-logo-provider/SessionProviderLogo.tsx b/src/components/llm-logo-provider/SessionProviderLogo.tsx index e0fe599..53b4236 100644 --- a/src/components/llm-logo-provider/SessionProviderLogo.tsx +++ b/src/components/llm-logo-provider/SessionProviderLogo.tsx @@ -5,6 +5,7 @@ import CodexLogo from './CodexLogo'; import CursorLogo from './CursorLogo'; import OpenCodeLogo from './OpenCodeLogo'; import GjcLogo from './GjcLogo'; +import OmoLogo from './OmoLogo'; import OmpLogo from './OmpLogo'; type SessionProviderLogoProps = { @@ -36,5 +37,9 @@ export default function SessionProviderLogo({ return ; } + if (provider === 'omo') { + return ; + } + return ; } diff --git a/src/components/main-content/types/types.ts b/src/components/main-content/types/types.ts index 7313093..c94201f 100644 --- a/src/components/main-content/types/types.ts +++ b/src/components/main-content/types/types.ts @@ -26,7 +26,7 @@ export type MainContentProps = { liveSessionModel: string | null; liveSessionEffort: string | null; liveSessionName: string | null; - liveSessionKind: 'gjc' | 'codex' | 'claude' | 'cursor' | 'opencode' | 'omp' | null; + liveSessionKind: 'gjc' | 'codex' | 'claude' | 'cursor' | 'opencode' | 'omp' | 'omo' | null; /** True while the viewed live/external session is running a turn. */ liveSessionProcessing?: boolean; activeTab: AppTab; diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index dd029d6..1361485 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -328,6 +328,7 @@ function MainContent({ cursor: 'Cursor', opencode: 'OpenCode', omp: 'Oh My Pi', + omo: 'omo', }[externalTerminal.cliKind]; const pendingCliAttachTarget = buildTranscriptCliAttachTarget({ tmux: externalTerminal.tmux, diff --git a/src/components/provider-auth/types.ts b/src/components/provider-auth/types.ts index 5877050..8ee36bf 100644 --- a/src/components/provider-auth/types.ts +++ b/src/components/provider-auth/types.ts @@ -10,7 +10,7 @@ export type ProviderAuthStatus = { export type ProviderAuthStatusMap = Record; -export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'opencode', 'omp']; +export const CLI_PROVIDERS: LLMProvider[] = ['claude', 'cursor', 'codex', 'opencode', 'omp', 'omo']; export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record = { claude: '/api/providers/claude/auth/status', @@ -19,6 +19,7 @@ export const PROVIDER_AUTH_STATUS_ENDPOINTS: Record = { opencode: '/api/providers/opencode/auth/status', gjc: '/api/providers/gjc/auth/status', omp: '/api/providers/omp/auth/status', + omo: '/api/providers/omo/auth/status', }; export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuthStatusMap => ({ @@ -28,4 +29,5 @@ export const createInitialProviderAuthStatusMap = (loading = true): ProviderAuth opencode: { authenticated: false, email: null, method: null, error: null, loading }, gjc: { authenticated: false, email: null, method: null, error: null, loading }, omp: { authenticated: false, email: null, method: null, error: null, loading }, + omo: { authenticated: false, email: null, method: null, error: null, loading }, }); diff --git a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx index 0727571..c86b731 100644 --- a/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx +++ b/src/components/settings/view/tabs/agents-settings/AgentListItem.tsx @@ -40,6 +40,10 @@ const agentConfig: Record = { name: 'Oh My Pi', color: 'gray', }, + omo: { + name: 'omo', + color: 'gray', + }, }; const colorClasses = { diff --git a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx index 8403a3f..4b0e942 100644 --- a/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx +++ b/src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx @@ -21,12 +21,13 @@ export default function AgentsSettingsTab({ const [selectedAgent, setSelectedAgent] = useState('claude'); const [selectedCategory, setSelectedCategory] = useState('account'); const visibleCategories = useMemo(() => { - if (selectedAgent === 'omp') return ['account']; + // Neither pi-derived CLI exposes a ChatMux-managed permission surface. + if (selectedAgent === 'omp' || selectedAgent === 'omo') return ['account']; return ['account', 'permissions']; }, [selectedAgent]); const visibleAgents = useMemo(() => { - return ['claude', 'cursor', 'codex', 'opencode', 'omp']; + return ['claude', 'cursor', 'codex', 'opencode', 'omp', 'omo']; }, []); const agentContextById = useMemo>(() => ({ @@ -54,6 +55,10 @@ export default function AgentsSettingsTab({ authStatus: providerAuthStatus.omp, onLogin: () => onProviderLogin('omp'), }, + omo: { + authStatus: providerAuthStatus.omo, + onLogin: () => onProviderLogin('omo'), + }, }), [ onProviderLogin, providerAuthStatus.claude, @@ -62,6 +67,7 @@ export default function AgentsSettingsTab({ providerAuthStatus.gjc, providerAuthStatus.opencode, providerAuthStatus.omp, + providerAuthStatus.omo, ]); useEffect(() => { diff --git a/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx b/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx index f2d492e..3f7fff7 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx @@ -10,6 +10,7 @@ const AGENT_NAMES: Record = { opencode: 'OpenCode', gjc: 'Gajae Code', omp: 'Oh My Pi', + omo: 'omo', }; export default function AgentSelectorSection({ diff --git a/src/components/settings/view/tabs/agents-settings/sections/content/AccountContent.tsx b/src/components/settings/view/tabs/agents-settings/sections/content/AccountContent.tsx index 49a1a31..973eec2 100644 --- a/src/components/settings/view/tabs/agents-settings/sections/content/AccountContent.tsx +++ b/src/components/settings/view/tabs/agents-settings/sections/content/AccountContent.tsx @@ -64,6 +64,15 @@ const agentConfig: Record = { subtextClass: 'text-zinc-700 dark:text-zinc-300', buttonClass: 'bg-zinc-900 hover:bg-zinc-800 active:bg-zinc-950 dark:bg-zinc-700 dark:hover:bg-zinc-600', }, + omo: { + name: 'omo', + description: 'omo coding agent', + bgClass: 'bg-zinc-50 dark:bg-zinc-900/20', + borderClass: 'border-zinc-200 dark:border-zinc-700', + textClass: 'text-zinc-900 dark:text-zinc-100', + subtextClass: 'text-zinc-700 dark:text-zinc-300', + buttonClass: 'bg-zinc-900 hover:bg-zinc-800 active:bg-zinc-950 dark:bg-zinc-700 dark:hover:bg-zinc-600', + }, omp: { name: 'Oh My Pi', description: 'Oh My Pi coding agent', diff --git a/src/components/sidebar/hooks/useExternalCliSessions.ts b/src/components/sidebar/hooks/useExternalCliSessions.ts index 09a63d0..7fa5d78 100644 --- a/src/components/sidebar/hooks/useExternalCliSessions.ts +++ b/src/components/sidebar/hooks/useExternalCliSessions.ts @@ -13,7 +13,7 @@ export type ExternalCliSession = { tmuxName: string; tmux: TmuxPaneIdentity; process: TmuxProcessGeneration | null; - kind: 'claude' | 'codex' | 'cursor' | 'opencode' | 'omp' | 'ssh' | 'shell'; + kind: 'claude' | 'codex' | 'cursor' | 'opencode' | 'omp' | 'omo' | 'ssh' | 'shell'; projectPath?: string; transcriptSessionId?: string; sessionName?: string; diff --git a/src/components/sidebar/view/subcomponents/SidebarExternalSection.tsx b/src/components/sidebar/view/subcomponents/SidebarExternalSection.tsx index 855c267..e9a643d 100644 --- a/src/components/sidebar/view/subcomponents/SidebarExternalSection.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarExternalSection.tsx @@ -19,6 +19,7 @@ const KIND_LABEL: Record = { cursor: 'Cursor', opencode: 'OpenCode', omp: 'Oh My Pi', + omo: 'omo', ssh: 'ssh (remote)', shell: 'terminal', }; diff --git a/src/components/sidebar/view/subcomponents/SidebarNewSession.tsx b/src/components/sidebar/view/subcomponents/SidebarNewSession.tsx index a453292..11c3e95 100644 --- a/src/components/sidebar/view/subcomponents/SidebarNewSession.tsx +++ b/src/components/sidebar/view/subcomponents/SidebarNewSession.tsx @@ -6,7 +6,7 @@ import { api } from '../../../../utils/api'; import HomeDirInput from '../../../../shared/view/HomeDirInput'; import { cn } from '../../../../lib/utils'; -type SpawnProvider = 'gjc' | 'codex' | 'claude' | 'cursor' | 'opencode' | 'omp'; +type SpawnProvider = 'gjc' | 'codex' | 'claude' | 'cursor' | 'opencode' | 'omp' | 'omo'; type SpawnStatus = | { kind: 'idle' } @@ -20,6 +20,7 @@ const PROVIDERS: { id: SpawnProvider; label: string }[] = [ { id: 'cursor', label: 'Cursor' }, { id: 'opencode', label: 'OpenCode' }, { id: 'omp', label: 'Oh My Pi' }, + { id: 'omo', label: 'omo' }, ]; // Working directories of successful spawns, most recent first. Typing an diff --git a/src/types/app.ts b/src/types/app.ts index d1f0df7..1b07459 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -1,6 +1,6 @@ import type { TmuxPaneIdentity, TmuxProcessGeneration } from '../../shared/tmux'; -export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gjc' | 'omp'; +export type LLMProvider = 'claude' | 'cursor' | 'codex' | 'opencode' | 'gjc' | 'omp' | 'omo'; export type ProviderModelOption = { value: string; @@ -41,7 +41,7 @@ export type ExternalTerminalTarget = { tmux: TmuxPaneIdentity; process: TmuxProcessGeneration | null; kind: string; - cliKind: 'claude' | 'codex' | 'cursor' | 'opencode' | 'omp' | 'ssh' | 'shell'; + cliKind: 'claude' | 'codex' | 'cursor' | 'opencode' | 'omp' | 'omo' | 'ssh' | 'shell'; project: Project | null; projectPath?: string; /** Opens the structured transcript instead of attaching a terminal. */