Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,6 @@ import {
spawnOmp,
abortOmpSession,
} from './omp-cli.js';
import {
spawnOmo,
abortOmoSession,
} from './omo-cli.js';
import {
stripAnsiSequences,
normalizeDetectedUrl,
Expand Down Expand Up @@ -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,
Expand All @@ -183,7 +186,6 @@ const wss = createWebSocketServer(server, {
opencode: abortOpenCodeSession,
gjc: abortGjcSession,
omp: abortOmpSession,
omo: abortOmoSession,
},
resolveToolApproval: resolveProviderToolApproval,
getPendingApprovalsForSession: getPendingProviderApprovalsForSession,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ type TerminalCompletionDecisionResult = ReturnType<typeof completionNotification
type GenerationObservation = Parameters<typeof completionNotificationTargetsDb.observeGeneration>[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<ExternalSessionActivityResolutionResult, { status: 'resolved' }>;
type MonitorResolvedActivity = ResolvedActivity & {
Expand Down Expand Up @@ -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<string, string>)[session.kind] ?? 'Assistant';
return {
title,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<PiTranscriptProvider, string> = {
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<PiTranscriptProvider, string> = {
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[];
Expand All @@ -84,19 +102,15 @@ 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')]
: [];
this.sessionRoots = [...new Set([
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`;
}
Expand Down
2 changes: 1 addition & 1 deletion server/modules/providers/provider.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,10 @@ const PROVIDER_CAPABILITIES: Record<LLMProvider, ProviderCapabilities> = {
provider: 'omo',
permissionModes: ['default'],
defaultPermissionMode: 'default',
// Mirrors the Oh My Pi posture: buildPiCliArgs forwards image paths as
// @<path> 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
Expand Down
4 changes: 4 additions & 0 deletions server/modules/providers/services/sessions-watcher.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
30 changes: 30 additions & 0 deletions server/modules/providers/tests/pi-transcript-roots.test.ts
Original file line number Diff line number Diff line change
@@ -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}`,
);
}
});
19 changes: 17 additions & 2 deletions server/omo-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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 <id>` 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' }),
Expand Down
2 changes: 1 addition & 1 deletion server/omo-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion server/omp-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
29 changes: 29 additions & 0 deletions server/pi-cli.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
70 changes: 59 additions & 11 deletions server/pi-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,22 @@ export type PiCliRunOptions = {

type ActivePiProcess = ReturnType<typeof spawn> & { 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
Expand All @@ -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 <id>` 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<PiCliProvider, string> = {
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);

Expand Down Expand Up @@ -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<void>((resolve, reject) => {
const finish = (error?: Error): void => {
Expand Down Expand Up @@ -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'],
Expand All @@ -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) => {
Expand All @@ -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 }));
}
Expand Down
2 changes: 1 addition & 1 deletion server/routes/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
1 change: 0 additions & 1 deletion src/components/chat/view/ChatInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Loading