From c05f6e34ac6bf47c87ec3a1814c51ac01b55bd16 Mon Sep 17 00:00:00 2001 From: Hako Date: Thu, 13 Aug 2026 19:39:51 +0900 Subject: [PATCH 1/3] fix(providers): register omo in the runtime lists the type system cannot see Adding omo to the unions in #39 left three runtime lookups untouched, so the provider typechecked and shipped while behaving wrongly at every layer. - omo read gjc's transcripts. The agent home came from `provider === 'omp' ? '.omp' : '.gjc'`, and a ternary routes a new union member into its else branch without a type error. 632 gjc sessions were indexed as omo, and none of omo's own five transcripts were ever read. - omo never appeared in the sidebar. Two `Array.includes` allowlists in useExternalCliSessions filtered it out after the backend reported it. - omo replies never refreshed. Its transcript root was missing from PROVIDER_WATCH_PATHS, so the initial scan found sessions that no watcher ever updated. The root and title lookups are now exhaustive Records, which makes the next provider a compile error instead of a silent fallthrough. The remaining allowlists (external spawn, completion identity, event-driven notifications, notification label) get omo wherever omp already appears. Also swaps the placeholder logo for the official `oh-my-openagent/.github/assets/omo-icon-light.svg`. Co-Authored-By: Claude Opus 5 --- .../completion-target-identity.service.ts | 2 +- .../services/external-turn-monitor.service.ts | 6 +- .../gjc/gjc-session-synchronizer.provider.ts | 26 ++++-- server/modules/providers/provider.routes.ts | 2 +- .../services/sessions-watcher.service.ts | 4 + .../tests/pi-transcript-roots.test.ts | 30 +++++++ server/routes/settings.js | 2 +- src/components/llm-logo-provider/OmoLogo.tsx | 79 ++++++++++++------- .../sidebar/hooks/useExternalCliSessions.ts | 4 +- 9 files changed, 114 insertions(+), 41 deletions(-) create mode 100644 server/modules/providers/tests/pi-transcript-roots.test.ts diff --git a/server/modules/database/services/completion-target-identity.service.ts b/server/modules/database/services/completion-target-identity.service.ts index 1284b33..4a0e011 100644 --- a/server/modules/database/services/completion-target-identity.service.ts +++ b/server/modules/database/services/completion-target-identity.service.ts @@ -4,7 +4,7 @@ import type { ExternalCliSession } from '@/modules/providers/index.js'; const IDENTITY_VERSION = 'completion-target/v1'; const APP_ALIAS_PREFIX = 'ct_'; -const EXTERNAL_PROVIDERS = new Set(['claude', 'codex', 'opencode', 'omp']); +const EXTERNAL_PROVIDERS = new Set(['claude', 'codex', 'opencode', 'omp', 'omo']); export type CompletionAppIdentity = Readonly<{ provider: string; diff --git a/server/modules/notifications/services/external-turn-monitor.service.ts b/server/modules/notifications/services/external-turn-monitor.service.ts index 485e1e0..5fa5f63 100644 --- a/server/modules/notifications/services/external-turn-monitor.service.ts +++ b/server/modules/notifications/services/external-turn-monitor.service.ts @@ -34,7 +34,7 @@ type TerminalCompletionDecisionResult = ReturnType[2]; const DEFAULT_INTERVAL_MS = TURN_MONITOR_FALLBACK_MS; -const EVENT_DRIVEN_EXTERNAL_PROVIDERS = new Set(['claude', 'codex', 'omp', 'opencode']); +const EVENT_DRIVEN_EXTERNAL_PROVIDERS = new Set(['claude', 'codex', 'omp', 'omo', 'opencode']); type ResolvedActivity = Extract; type MonitorResolvedActivity = ResolvedActivity & { @@ -174,10 +174,12 @@ function completionPayload( const title = typeof session.tmuxName === 'string' && session.tmuxName.trim() ? session.tmuxName.trim() : 'ChatMux'; - const label = session.kind === 'omp' ? 'Oh My Pi' : ({ + const label = ({ claude: 'Claude', codex: 'Codex', opencode: 'OpenCode', + omp: 'Oh My Pi', + omo: 'omo', } as Record)[session.kind] ?? 'Assistant'; return { title, 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 34fa1ba..5ade1b0 100644 --- a/server/modules/providers/list/gjc/gjc-session-synchronizer.provider.ts +++ b/server/modules/providers/list/gjc/gjc-session-synchronizer.provider.ts @@ -75,6 +75,24 @@ function extractGjcTextFromContent(content: unknown): string { * live on the top-level header line (`{"type":"session","id":..,"cwd":..}`), * unlike Codex which nests them under `payload`. */ +/** + * Per-provider home directories. These are exhaustive Records rather than + * `provider === 'x' ? … : …` on purpose: a ternary silently routes any new + * union member into its else branch, which is exactly how omo ended up + * scanning gjc's transcripts. A Record makes adding a provider a type error. + */ +export const PI_AGENT_ROOT_DIRS: Record = { + gjc: path.join(os.homedir(), '.gjc', 'agent'), + omp: path.join(os.homedir(), '.omp', 'agent'), + omo: path.join(os.homedir(), '.omo', 'agent'), +}; + +const PI_UNTITLED_SESSION_TITLES: Record = { + gjc: 'Untitled gjc Session', + omp: 'Untitled Oh My Pi Session', + omo: 'Untitled omo Session', +}; + export class GjcSessionSynchronizer implements IProviderSessionSynchronizer { private readonly provider: PiTranscriptProvider; private readonly sessionRoots: string[]; @@ -84,9 +102,7 @@ export class GjcSessionSynchronizer implements IProviderSessionSynchronizer { constructor(options: PiSessionSynchronizerOptions = {}) { this.provider = options.provider ?? 'gjc'; - const agentRoot = this.provider === 'omp' - ? path.join(os.homedir(), '.omp', 'agent') - : path.join(os.homedir(), '.gjc', 'agent'); + const agentRoot = PI_AGENT_ROOT_DIRS[this.provider]; const defaultAdditionalRoots = this.provider === 'gjc' ? [process.env.GJC_LIVE_SESSION_DIR || path.join(os.tmpdir(), 'gjc-live-sessions')] : []; @@ -94,9 +110,7 @@ export class GjcSessionSynchronizer implements IProviderSessionSynchronizer { options.sessionsDir ?? path.join(agentRoot, 'sessions'), ...(options.additionalSessionDirs ?? defaultAdditionalRoots), ])]; - this.untitledSession = this.provider === 'omp' - ? 'Untitled Oh My Pi Session' - : 'Untitled gjc Session'; + this.untitledSession = PI_UNTITLED_SESSION_TITLES[this.provider]; this.initialScanDoneKey = `${this.provider}_initial_scan_done`; this.pendingSessionFilesKey = `${this.provider}_pending_session_files`; } diff --git a/server/modules/providers/provider.routes.ts b/server/modules/providers/provider.routes.ts index bba91a8..9599bbc 100644 --- a/server/modules/providers/provider.routes.ts +++ b/server/modules/providers/provider.routes.ts @@ -959,7 +959,7 @@ router.post( statusCode: 400, }); } - const supportedClis: ExternalSpawnCli[] = ['claude', 'codex', 'cursor', 'opencode', 'omp']; + const supportedClis: ExternalSpawnCli[] = ['claude', 'codex', 'cursor', 'opencode', 'omp', 'omo']; if (body.cli !== undefined && !supportedClis.includes(body.cli as ExternalSpawnCli)) { throw new AppError(`cli must be one of: ${supportedClis.join(', ')}.`, { code: 'INVALID_CLI', diff --git a/server/modules/providers/services/sessions-watcher.service.ts b/server/modules/providers/services/sessions-watcher.service.ts index 359d5e1..ece753a 100644 --- a/server/modules/providers/services/sessions-watcher.service.ts +++ b/server/modules/providers/services/sessions-watcher.service.ts @@ -36,6 +36,10 @@ const PROVIDER_WATCH_PATHS: Array<{ provider: LLMProvider; rootPath: string }> = provider: 'omp', rootPath: path.join(os.homedir(), '.omp', 'agent', 'sessions'), }, + { + provider: 'omo', + rootPath: path.join(os.homedir(), '.omo', 'agent', 'sessions'), + }, ]; const GJC_TERMINAL_RECEIPT_ROOT = path.join(os.homedir(), '.gjc', 'agent', 'terminal-sessions'); diff --git a/server/modules/providers/tests/pi-transcript-roots.test.ts b/server/modules/providers/tests/pi-transcript-roots.test.ts new file mode 100644 index 0000000..9cd7a1c --- /dev/null +++ b/server/modules/providers/tests/pi-transcript-roots.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { PI_AGENT_ROOT_DIRS } from '@/modules/providers/list/gjc/gjc-session-synchronizer.provider.js'; + +/** + * Regression lock for the omo mis-attribution: the root lookup used to be + * `provider === 'omp' ? '.omp' : '.gjc'`, so adding omo to the union silently + * pointed it at gjc's transcripts. 632 gjc sessions were indexed as omo and the + * whole suite still passed, because a ternary else branch is not a type error. + */ +test('every pi provider reads its own agent home, and no two share one', () => { + assert.deepEqual(PI_AGENT_ROOT_DIRS, { + gjc: path.join(os.homedir(), '.gjc', 'agent'), + omp: path.join(os.homedir(), '.omp', 'agent'), + omo: path.join(os.homedir(), '.omo', 'agent'), + }); + + const roots = Object.values(PI_AGENT_ROOT_DIRS); + assert.equal(new Set(roots).size, roots.length, 'two providers resolve to the same root'); + + for (const [provider, root] of Object.entries(PI_AGENT_ROOT_DIRS)) { + assert.ok( + root.includes(`/.${provider}/`), + `${provider} must read ~/.${provider}, got ${root}`, + ); + } +}); diff --git a/server/routes/settings.js b/server/routes/settings.js index 7b662b2..f79c8fc 100644 --- a/server/routes/settings.js +++ b/server/routes/settings.js @@ -73,7 +73,7 @@ const validExternalDescriptorSession = (session) => isRecord(session) && boundedString(session.tmux.paneId, 128) && Number.isSafeInteger(session.agentPid) && session.agentPid > 0 && Number.isFinite(session.startedAtMs) && session.startedAtMs > 0; -const externalCompletionKinds = new Set(['claude', 'codex', 'opencode', 'omp']); +const externalCompletionKinds = new Set(['claude', 'codex', 'opencode', 'omp', 'omo']); const validDetailedExternalSession = (session) => isRecord(session) && boundedString(session.kind, 32) && (!externalCompletionKinds.has(session.kind) || validExternalDescriptorSession(session)); diff --git a/src/components/llm-logo-provider/OmoLogo.tsx b/src/components/llm-logo-provider/OmoLogo.tsx index bdc0f70..fa89267 100644 --- a/src/components/llm-logo-provider/OmoLogo.tsx +++ b/src/components/llm-logo-provider/OmoLogo.tsx @@ -1,34 +1,57 @@ -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 ( - - - - - - - - - - - ); -}; +/** Official omo mark from oh-my-openagent `.github/assets/omo-icon-light.svg`. */ +const OmoLogo = ({ className = 'w-5 h-5' }: OmoLogoProps) => ( + + + + + + + + +); export default OmoLogo; diff --git a/src/components/sidebar/hooks/useExternalCliSessions.ts b/src/components/sidebar/hooks/useExternalCliSessions.ts index 7fa5d78..8ca6f1f 100644 --- a/src/components/sidebar/hooks/useExternalCliSessions.ts +++ b/src/components/sidebar/hooks/useExternalCliSessions.ts @@ -38,7 +38,7 @@ export function mergeExternalDiscoveryRows( session, ])); return rows - .filter((row) => row.lane === 'external' && ['claude', 'codex', 'cursor', 'opencode', 'omp', 'ssh', 'shell'].includes(row.kind)) + .filter((row) => row.lane === 'external' && ['claude', 'codex', 'cursor', 'opencode', 'omp', 'omo', 'ssh', 'shell'].includes(row.kind)) .map((row) => { const metadata = restSessions.get(tmuxPaneIdentityKey(row.tmux)) ?? previous.get(tmuxPaneIdentityKey(row.tmux)); const { connectionIssue: _staleConnectionIssue, ...stableMetadata } = metadata ?? {}; @@ -155,7 +155,7 @@ export function useExternalCliSessions( const applyRestSessions = useCallback((list: ExternalCliSession[], responseDiscoveryOk: boolean) => { const supported = list.filter((session) => ( session?.tmuxName - && ['claude', 'codex', 'cursor', 'opencode', 'omp', 'ssh', 'shell'].includes(session.kind) + && ['claude', 'codex', 'cursor', 'opencode', 'omp', 'omo', 'ssh', 'shell'].includes(session.kind) )); setDiscoveryOk(responseDiscoveryOk); if (!responseDiscoveryOk) { From a2f7898c0c4a942c8353fa3fd82b76dc5e77efae Mon Sep 17 00:00:00 2001 From: Hako Date: Thu, 13 Aug 2026 19:46:12 +0900 Subject: [PATCH 2/3] fix(chat): label omo assistant messages as omo, not Claude The message-author chain ends in `: t('messageTypes.claude')`, so omo fell through to the Claude label the same way its transcript root fell through to gjc. Follows the gjc/omp pattern of an i18n key with a defaultValue, so no locale file changes are needed. Co-Authored-By: Claude Opus 5 --- src/components/chat/view/subcomponents/MessageComponent.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx index 5e7c091..64e505c 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.tsx @@ -204,7 +204,9 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s ? t('messageTypes.gjc', { defaultValue: 'Gajae Code' }) : provider === 'omp' ? t('messageTypes.omp', { defaultValue: 'Oh My Pi' }) - : t('messageTypes.claude'))} + : provider === 'omo' + ? t('messageTypes.omo', { defaultValue: 'omo' }) + : t('messageTypes.claude'))} )} From f02d60835cad15cb5703c9d13f309bc671fa5fd7 Mon Sep 17 00:00:00 2001 From: Hako Date: Thu, 13 Aug 2026 20:48:48 +0900 Subject: [PATCH 3/3] fix(providers): correct omo session flag, stderr handling, and avatar display Four defects found by running omo through ChatMux end to end. - omo could not continue a session. `--resume` means different things in the two CLIs: Oh My Pi resumes the given id, omo takes no value and opens an interactive picker, which under `--print` with no stdin exits 13 without running the turn. Measured across valid id, wrong cwd, and `--resume=`; all exit non-zero. omo's equivalent is `--session-id`, verified to resume an existing id and create a missing one. - Ordinary CLI logging surfaced as chat errors. Both CLIs write config notices and hook status to stderr on runs that exit 0, and every line was forwarded as an error message. stderr is now buffered and reported only when the process actually fails. - Assistant avatars were gjc-only. `transcriptView` existed solely to hide the author row for every live provider except gjc; the prop is removed, so all providers show their icon and name. omo also fell through the author chain to the Claude label. - The omo logo rendered small. The official asset sits at ~63% of its canvas, so the viewBox is cropped to the mark's measured bounds. omo's send runtime stays unregistered in spawnFns: sending to a session that is already live in a tmux pane would start a second headless process on the same transcript. Filed separately. Co-Authored-By: Claude Opus 5 --- server/index.js | 14 ++-- .../services/provider-capabilities.service.ts | 7 +- server/omo-cli.test.ts | 19 ++++- server/omo-cli.ts | 2 +- server/omp-cli.ts | 2 +- server/pi-cli.test.ts | 29 ++++++++ server/pi-cli.ts | 70 ++++++++++++++++--- src/components/chat/view/ChatInterface.tsx | 1 - .../view/subcomponents/ChatMessagesPane.tsx | 4 -- .../view/subcomponents/MessageComponent.tsx | 5 +- .../view/subcomponents/ToolGroupContainer.tsx | 3 - src/components/llm-logo-provider/OmoLogo.tsx | 13 +++- 12 files changed, 132 insertions(+), 37 deletions(-) create mode 100644 server/pi-cli.test.ts diff --git a/server/index.js b/server/index.js index 3208943..0766706 100755 --- a/server/index.js +++ b/server/index.js @@ -63,10 +63,6 @@ import { spawnOmp, abortOmpSession, } from './omp-cli.js'; -import { - spawnOmo, - abortOmoSession, -} from './omo-cli.js'; import { stripAnsiSequences, normalizeDetectedUrl, @@ -174,7 +170,14 @@ const wss = createWebSocketServer(server, { opencode: spawnOpenCode, gjc: spawnGjc, omp: spawnOmp, - omo: spawnOmo, + // omo is intentionally NOT registered. Its transcripts are indexed + // like any other session, so a session that is currently live in a + // tmux pane can also be opened here; spawning would start a second + // headless omo on the same --session-id and both processes would + // append to one transcript. Observed: a full turn landed in a live + // session that the running agent never saw. Live sessions must go + // through the tmux relay. Re-enable only behind a guard that + // refuses to spawn when the session is live. }, abortFns: { claude: abortClaudeSDKSession, @@ -183,7 +186,6 @@ const wss = createWebSocketServer(server, { opencode: abortOpenCodeSession, gjc: abortGjcSession, omp: abortOmpSession, - omo: abortOmoSession, }, resolveToolApproval: resolveProviderToolApproval, getPendingApprovalsForSession: getPendingProviderApprovalsForSession, diff --git a/server/modules/providers/services/provider-capabilities.service.ts b/server/modules/providers/services/provider-capabilities.service.ts index 09a2e84..5992bcc 100644 --- a/server/modules/providers/services/provider-capabilities.service.ts +++ b/server/modules/providers/services/provider-capabilities.service.ts @@ -113,9 +113,10 @@ const PROVIDER_CAPABILITIES: Record = { provider: 'omo', permissionModes: ['default'], defaultPermissionMode: 'default', - // Mirrors the Oh My Pi posture: buildPiCliArgs forwards image paths as - // @ and createPiCliRuntime terminates the tracked child with SIGTERM - // (`server/pi-cli.ts`), but this matrix still hides both UI controls. + // omo is discovery, transcript, and tmux relay only. `server/omo-cli.ts` + // implements the send runtime, but it is deliberately not registered in + // `spawnFns` (see the comment there): spawning would put a second headless + // omo on a session that may already be live in a tmux pane. supportsImages: false, supportsAbort: false, // omo's TUI renders "↑↓ navigate • enter select • esc close" and has no diff --git a/server/omo-cli.test.ts b/server/omo-cli.test.ts index 685c670..188d1fb 100644 --- a/server/omo-cli.test.ts +++ b/server/omo-cli.test.ts @@ -2,8 +2,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { buildOmoArgs, normalizeOmoEvent } from './omo-cli.js'; +import { buildOmpArgs } from './omp-cli.js'; -test('buildOmoArgs preserves resume, model, thinking, images, and prompt as distinct argv', () => { +test('buildOmoArgs continues a session with --session-id, never --resume', () => { assert.deepEqual( buildOmoArgs('Explain this image', { sessionId: '019ff9fa-abab-78a3-83b0-67c261374f42', @@ -13,7 +14,7 @@ test('buildOmoArgs preserves resume, model, thinking, images, and prompt as dist }), [ '--mode', 'json', '--print', - '--resume', '019ff9fa-abab-78a3-83b0-67c261374f42', + '--session-id', '019ff9fa-abab-78a3-83b0-67c261374f42', '--model', 'anthropic/claude-opus-5', '--thinking', 'high', '@/tmp/shot.png', @@ -22,6 +23,20 @@ test('buildOmoArgs preserves resume, model, thinking, images, and prompt as dist ); }); +// omo's `--resume` takes no value and opens an interactive picker; under +// `--print` with no stdin it exits 13 without running the turn, so every +// follow-up message in a session failed. Oh My Pi's `--resume ` is the +// unrelated flag that happens to share the name. +test('omo and Oh My Pi do not share a session flag', () => { + const omo = buildOmoArgs('hi', { sessionId: 'S' }); + const omp = buildOmpArgs('hi', { sessionId: 'S' }); + + assert.ok(omo.includes('--session-id'), 'omo must use --session-id'); + assert.ok(!omo.includes('--resume'), 'omo must never receive --resume'); + assert.ok(omp.includes('--resume'), 'Oh My Pi keeps --resume'); + assert.ok(!omp.includes('--session-id')); +}); + test('buildOmoArgs omits placeholder model and effort selections', () => { assert.deepEqual( buildOmoArgs('hi', { model: 'default', effort: 'default' }), diff --git a/server/omo-cli.ts b/server/omo-cli.ts index 1646453..fdd1b39 100644 --- a/server/omo-cli.ts +++ b/server/omo-cli.ts @@ -17,7 +17,7 @@ const OMO: PiCliDescriptor = { const runtime = createPiCliRuntime(OMO); export function buildOmoArgs(command: string, options: PiCliRunOptions): string[] { - return buildPiCliArgs(command, options); + return buildPiCliArgs(command, options, OMO.provider); } export function normalizeOmoEvent( diff --git a/server/omp-cli.ts b/server/omp-cli.ts index 15cd8de..c1f0581 100644 --- a/server/omp-cli.ts +++ b/server/omp-cli.ts @@ -17,7 +17,7 @@ const OMP: PiCliDescriptor = { const runtime = createPiCliRuntime(OMP); export function buildOmpArgs(command: string, options: PiCliRunOptions): string[] { - return buildPiCliArgs(command, options); + return buildPiCliArgs(command, options, OMP.provider); } export function normalizeOmpEvent( diff --git a/server/pi-cli.test.ts b/server/pi-cli.test.ts new file mode 100644 index 0000000..83a1928 --- /dev/null +++ b/server/pi-cli.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { piCliFailureDetail } from './pi-cli.js'; + +// Verbatim stderr from a successful `omo --mode json --print` run (exit 0). +const SUCCESSFUL_RUN_STDERR = [ + "config-watch user config discovery requires reload { userConfigCreationDiscovery: 'reload_required' }", + "omo-senpi ulw-loop status ignored { reason: 'non-zero-exit', code: 1 }", + "omo-senpi start-work-continuation skipped { reason: 'not-continuable' }", +].join('\n'); + +test('a clean exit never reports its stderr as a failure', () => { + assert.equal(piCliFailureDetail(0, false, SUCCESSFUL_RUN_STDERR), null); + assert.equal( + piCliFailureDetail(0, false, 'Warning: Detected unsettled top-level await at file:///…/cli-main.js:17'), + null, + ); +}); + +test('a failing exit surfaces the buffered stderr', () => { + assert.equal(piCliFailureDetail(1, false, ' boom\n'), 'boom'); + assert.equal(piCliFailureDetail(null, false, 'killed mid-turn'), 'killed mid-turn'); +}); + +test('an aborted run stays silent, and a failure with no stderr adds nothing', () => { + assert.equal(piCliFailureDetail(143, true, 'terminated'), null); + assert.equal(piCliFailureDetail(1, false, ' \n '), null); +}); diff --git a/server/pi-cli.ts b/server/pi-cli.ts index c115edb..09008a6 100644 --- a/server/pi-cli.ts +++ b/server/pi-cli.ts @@ -43,6 +43,22 @@ export type PiCliRunOptions = { type ActivePiProcess = ReturnType & { aborted?: boolean }; +const MAX_BUFFERED_STDERR_BYTES = 64 * 1024; + +/** + * Buffered stderr is worth showing only when the run failed. An aborted run is + * a user gesture, and a clean exit means the lines were progress logs. + */ +export function piCliFailureDetail( + exitCode: number | null, + aborted: boolean, + stderr: string, +): string | null { + if (aborted || exitCode === 0) return null; + const detail = stderr.trim(); + return detail.length > 0 ? detail : null; +} + function readRecord(value: unknown): AnyRecord | null { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as AnyRecord @@ -61,9 +77,26 @@ function readContentText(value: unknown): string { .join('\n'); } -export function buildPiCliArgs(command: string, options: PiCliRunOptions): string[] { +/** + * The two CLIs spell session continuation differently, and the flag names + * overlap misleadingly. In Oh My Pi `--resume ` resumes that id; in omo + * `--resume` takes no value and opens an interactive picker, which under + * `--print` with no stdin exits 13 without ever running the turn. omo's + * equivalent is `--session-id`, which resumes an existing id and creates it + * when missing. + */ +const PI_SESSION_FLAGS: Record = { + omp: '--resume', + omo: '--session-id', +}; + +export function buildPiCliArgs( + command: string, + options: PiCliRunOptions, + provider: PiCliProvider = 'omp', +): string[] { const args = ['--mode', 'json', '--print']; - if (options.sessionId) args.push('--resume', options.sessionId); + if (options.sessionId) args.push(PI_SESSION_FLAGS[provider], options.sessionId); if (options.model && options.model !== 'default') args.push('--model', options.model); if (options.effort && options.effort !== 'default') args.push('--thinking', options.effort); @@ -179,6 +212,8 @@ export function createPiCliRuntime(descriptor: PiCliDescriptor): PiCliRuntime { let capturedSessionId = options.sessionId ?? null; let child: ActivePiProcess | null = null; let settled = false; + const stderrChunks: string[] = []; + let stderrBytes = 0; const run = new Promise((resolve, reject) => { const finish = (error?: Error): void => { @@ -210,7 +245,7 @@ export function createPiCliRuntime(descriptor: PiCliDescriptor): PiCliRuntime { try { // stdin must be closed: with an inherited stdin these CLIs wait for // interactive input and never emit their first event. - child = spawn(binary, buildPiCliArgs(command, options), { + child = spawn(binary, buildPiCliArgs(command, options, provider), { cwd: workingDir, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], @@ -237,15 +272,15 @@ export function createPiCliRuntime(descriptor: PiCliDescriptor): PiCliRuntime { for (const message of normalized.messages) writer.send(message); }); + // stderr is a log channel for these CLIs, not an error channel: a run + // that exits 0 still prints config notices and hook status there. + // Buffer it and surface it only when the process actually fails, so + // ordinary logging cannot masquerade as a failed turn in the chat. stderr.on('data', (chunk) => { - const content = String(chunk).trim(); - if (!content) return; - writer.send(createNormalizedMessage({ - kind: 'error', - content, - sessionId: capturedSessionId, - provider, - })); + if (stderrBytes >= MAX_BUFFERED_STDERR_BYTES) return; + const text = String(chunk); + stderrBytes += text.length; + stderrChunks.push(text); }); child.on('error', (error) => { @@ -266,6 +301,19 @@ export function createPiCliRuntime(descriptor: PiCliDescriptor): PiCliRuntime { child.on('close', (code) => { activeProcesses.delete(processKey); if (capturedSessionId) activeProcesses.delete(capturedSessionId); + const failureDetail = piCliFailureDetail( + typeof code === 'number' ? code : null, + Boolean(child?.aborted), + stderrChunks.join(''), + ); + if (failureDetail) { + writer.send(createNormalizedMessage({ + kind: 'error', + content: failureDetail, + sessionId: capturedSessionId, + provider, + })); + } if (!child?.aborted) { writer.send(createCompleteMessage({ provider, sessionId: capturedSessionId, exitCode: code })); } diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx index 0a34f8e..3f6dcc1 100644 --- a/src/components/chat/view/ChatInterface.tsx +++ b/src/components/chat/view/ChatInterface.tsx @@ -453,7 +453,6 @@ function ChatInterface({ showThinking={showThinking} showImagePreviews={showImagePreviews} selectedProject={selectedProject} - transcriptView={Boolean(liveSessionKind && liveSessionKind !== 'gjc')} pendingAskToolId={pendingRelayAsk?.toolId ?? null} suppressedAskToolId={suppressedAskToolId} onAskChoiceSelect={handleAskChoiceSelect} diff --git a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx index c970d3e..62314e8 100644 --- a/src/components/chat/view/subcomponents/ChatMessagesPane.tsx +++ b/src/components/chat/view/subcomponents/ChatMessagesPane.tsx @@ -64,7 +64,6 @@ interface ChatMessagesPaneProps { showThinking?: boolean; showImagePreviews?: boolean; selectedProject: Project; - transcriptView?: boolean; pendingAskToolId?: string | null; suppressedAskToolId?: string | null; onAskChoiceSelect?: (choiceNumber: number) => void; @@ -115,7 +114,6 @@ function ChatMessagesPane({ showThinking, showImagePreviews = true, selectedProject, - transcriptView = false, pendingAskToolId = null, suppressedAskToolId = null, onAskChoiceSelect, @@ -270,7 +268,6 @@ function ChatMessagesPane({ showImagePreviews={showImagePreviews} selectedProject={selectedProject} provider={provider} - transcriptView={transcriptView} pendingAskToolId={pendingAskToolId} suppressedAskToolId={suppressedAskToolId} onAskChoiceSelect={onAskChoiceSelect} @@ -295,7 +292,6 @@ function ChatMessagesPane({ showImagePreviews={showImagePreviews} selectedProject={selectedProject} provider={provider} - transcriptView={transcriptView} pendingAskToolId={pendingAskToolId} suppressedAskToolId={suppressedAskToolId} onAskChoiceSelect={onAskChoiceSelect} diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx index 64e505c..20173e9 100644 --- a/src/components/chat/view/subcomponents/MessageComponent.tsx +++ b/src/components/chat/view/subcomponents/MessageComponent.tsx @@ -37,7 +37,6 @@ type MessageComponentProps = { showImagePreviews?: boolean; selectedProject?: Project | null; provider: Provider | string; - transcriptView?: boolean; pendingAskToolId?: string | null; suppressedAskToolId?: string | null; onAskChoiceSelect?: (choiceNumber: number) => void; @@ -55,7 +54,7 @@ const compactErrorSummary = (content: string, fallback: string): string => { return firstLine.length > 160 ? `${firstLine.slice(0, 157)}...` : firstLine; }; -const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, showRawParameters, showThinking, showImagePreviews = true, selectedProject, provider, transcriptView = false, pendingAskToolId = null, suppressedAskToolId = null, onAskChoiceSelect }: MessageComponentProps) => { +const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, showRawParameters, showThinking, showImagePreviews = true, selectedProject, provider, pendingAskToolId = null, suppressedAskToolId = null, onAskChoiceSelect }: MessageComponentProps) => { const { t } = useTranslation('chat'); const isGrouped = prevMessage && prevMessage.type === message.type && ((prevMessage.type === 'assistant') || @@ -180,7 +179,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s ) : ( /* Claude/Error/Tool messages on the left */
- {!isGrouped && message.type !== 'error' && !(transcriptView && message.type === 'assistant') && ( + {!isGrouped && message.type !== 'error' && (
{message.type === 'tool' ? (
diff --git a/src/components/chat/view/subcomponents/ToolGroupContainer.tsx b/src/components/chat/view/subcomponents/ToolGroupContainer.tsx index 88c83b7..28fa4d7 100644 --- a/src/components/chat/view/subcomponents/ToolGroupContainer.tsx +++ b/src/components/chat/view/subcomponents/ToolGroupContainer.tsx @@ -27,7 +27,6 @@ interface ToolGroupContainerProps { showImagePreviews?: boolean; selectedProject?: Project | null; provider: Provider | string; - transcriptView?: boolean; pendingAskToolId?: string | null; suppressedAskToolId?: string | null; onAskChoiceSelect?: (choiceNumber: number) => void; @@ -75,7 +74,6 @@ export default function ToolGroupContainer({ showImagePreviews = true, selectedProject, provider, - transcriptView = false, pendingAskToolId = null, suppressedAskToolId = null, onAskChoiceSelect, @@ -146,7 +144,6 @@ export default function ToolGroupContainer({ showImagePreviews={showImagePreviews} selectedProject={selectedProject} provider={provider} - transcriptView={transcriptView} pendingAskToolId={pendingAskToolId} suppressedAskToolId={suppressedAskToolId} onAskChoiceSelect={onAskChoiceSelect} diff --git a/src/components/llm-logo-provider/OmoLogo.tsx b/src/components/llm-logo-provider/OmoLogo.tsx index fa89267..59596b8 100644 --- a/src/components/llm-logo-provider/OmoLogo.tsx +++ b/src/components/llm-logo-provider/OmoLogo.tsx @@ -2,10 +2,19 @@ type OmoLogoProps = { className?: string; }; -/** Official omo mark from oh-my-openagent `.github/assets/omo-icon-light.svg`. */ +/** + * Official omo mark from oh-my-openagent `.github/assets/omo-icon-light.svg`. + * + * Cropped to the cat itself rather than to the source canvas. Measured from a + * 512px render, the mark occupies x 190..832, y 220..794 of the 1024 canvas — + * only ~63% of the width — so the shipped viewBox renders it noticeably + * smaller than the other provider logos, which are full-bleed. This box is the + * mark centred (511, 507) with a ~10% margin; the light backing plate simply + * bleeds past the edges, which is what keeps a dark mark legible on dark UI. + */ const OmoLogo = ({ className = 'w-5 h-5' }: OmoLogoProps) => (