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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Codex for macOS is now the default frontend path for this repo's operator workfl

### Verified alignment with official OpenAI docs (February 2026)

- Codex app setup is macOS (Apple Silicon) and recommended for mac users.
- Codex app setup is macOS (Apple Silicon) and recommended for Mac users.
- App feature model includes Local / Worktree / Cloud modes, built-in Git, integrated terminal, automations, and MCP support.
- Codex CLI supports interactive mode, `resume`, `cloud`, `exec`, and `mcp` operations.
- Security defaults recommend workspace-write + on-request approvals for version-controlled repos.
Expand Down
21 changes: 19 additions & 2 deletions src/bridging/mcp-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ import { EventEmitter } from 'events';
import { setTimeout as sleep } from 'timers/promises';
import { Logger } from '../core/logger.js';

export class EndpointResolutionError extends Error {
constructor(message: string, cause?: Error) {
super(message);
this.name = 'EndpointResolutionError';
if (cause) {
this.cause = cause;
}
}
}

interface BridgeEndpoint {
name: string;
url: string;
Expand All @@ -32,6 +42,13 @@ export interface MCPBridgeResponse {
error?: StructuredBridgeError;
}

export interface MCPBridgeStatus {
isRunning: boolean;
connectedEndpoints: Array<{ name: string; url: string }>;
retryAttempts: number;
timeoutMs: number;
}

function parseIntEnv(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) {
Expand Down Expand Up @@ -122,7 +139,7 @@ export class MCPBridge extends EventEmitter {
return { name: endpoint, url: envUrl };
}

throw new Error(
throw new EndpointResolutionError(
`Endpoint "${endpoint}" is not connected. Provide an HTTP URL endpoint or set CODEX_MCP_ENDPOINT_${key}_URL.`
);
}
Expand All @@ -143,7 +160,7 @@ export class MCPBridge extends EventEmitter {
});
}

getStatus(): any {
getStatus(): MCPBridgeStatus {
return {
isRunning: this.isRunning,
connectedEndpoints: Array.from(this.connectedEndpoints.entries()).map(([name, value]) => ({
Expand Down
21 changes: 12 additions & 9 deletions src/cli/daemon-runner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'fs';
import { existsSync, mkdirSync, unlinkSync } from 'fs';
import { writeFile } from 'fs/promises';
import { join } from 'path';
import { homedir } from 'os';
import { createServer, type Server, type Socket } from 'net';
Expand Down Expand Up @@ -155,10 +156,10 @@ async function main() {
};
};

const persistSnapshot = () => {
const persistSnapshot = async () => {
try {
const snapshot = buildSnapshot();
writeFileSync(RUNTIME_FILE, JSON.stringify(snapshot, null, 2), 'utf8');
await writeFile(RUNTIME_FILE, JSON.stringify(snapshot, null, 2), 'utf8');
} catch (error) {
logger.debug('daemon', 'Failed to persist runtime snapshot', {
error: (error as Error).message
Expand Down Expand Up @@ -209,7 +210,7 @@ async function main() {

daemonState.currentMode = mode;
notify({ type: 'modeChanged', mode: daemonState.currentMode, tier: daemonState.currentTier });
persistSnapshot();
void persistSnapshot().catch(err => logger.debug('daemon', 'Background persist failed', { error: err.message }));
respond(socket, {
id: request.id,
ok: true,
Expand All @@ -234,7 +235,7 @@ async function main() {

daemonState.currentTier = tier;
notify({ type: 'modeChanged', mode: daemonState.currentMode, tier: daemonState.currentTier });
persistSnapshot();
void persistSnapshot().catch(err => logger.debug('daemon', 'Background persist failed', { error: err.message }));
respond(socket, {
id: request.id,
ok: true,
Expand Down Expand Up @@ -345,7 +346,7 @@ async function main() {
tier: daemonState.currentTier
});

persistSnapshot();
await persistSnapshot();
} catch (error) {
daemonState.currentMode = previousMode;
daemonState.currentTier = previousTier;
Expand All @@ -372,8 +373,10 @@ async function main() {

socketServer = await createSocketServer();

persistSnapshot();
runtimeTimer = setInterval(persistSnapshot, 1000);
await persistSnapshot();
runtimeTimer = setInterval(() => {
void persistSnapshot().catch(err => logger.debug('daemon', 'Periodic persist failed', { error: err.message }));
}, 1000);
if (typeof runtimeTimer.unref === 'function') {
runtimeTimer.unref();
}
Expand Down Expand Up @@ -416,7 +419,7 @@ async function main() {
});
socketServer = undefined;
}
persistSnapshot();
await persistSnapshot();
await system.shutdown();
logger.info('daemon', 'Background system shutdown complete');
} catch (error) {
Expand Down
46 changes: 35 additions & 11 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ import { InstructionParser } from '../instructions/index.js';
import { RoutingPolicyService, type RoutingRequest } from '../router/index.js';
import { readFileSync, existsSync } from 'fs';
import { join, resolve, relative } from 'path';
import { spawnSync } from 'child_process';
import { spawnSync, execFile } from 'child_process';
import { promisify } from 'util';
import { ToolOptimizer, type ToolCandidate } from '../tools/optimizer/index.js';
import { type ToolUsageRecord, type ReasoningRunRecord } from '../memory/memory-system.js';
import type { ReasoningPlanOptions, ReasoningCompletionOptions, ReasoningCheckpointInput } from '../reasoning/planner.js';
Expand Down Expand Up @@ -4435,6 +4436,35 @@ ${name}`));
});
});

const execFileAsync = promisify(execFile);

/**
* Execute codex CLI command with timeout and error handling
*/
async function execCodexCommand(args: string[], timeoutMs = 10000): Promise<{ stdout: string; stderr: string; exitCode: number }> {
try {
const { stdout, stderr } = await execFileAsync('codex', args, {
cwd: process.cwd(),
encoding: 'utf8',
timeout: timeoutMs
});
return { stdout, stderr, exitCode: 0 };
} catch (error: unknown) {
const err = error as { code?: string | number; killed?: boolean; signal?: string; stdout?: string; stderr?: string };
if (err.code === 'ENOENT') {
throw new Error('codex command not found. Ensure Codex CLI is installed and in PATH.');
}
if (err.killed && err.signal === 'SIGTERM') {
throw new Error(`codex command timed out after ${timeoutMs}ms`);
}
return {
stdout: err.stdout || '',
stderr: err.stderr || '',
exitCode: typeof err.code === 'number' ? err.code : 1
};
}
}

envCmd
.command('codex-register')
.description('Register MCP HTTP profiles in Codex CLI MCP config')
Expand All @@ -4448,21 +4478,15 @@ envCmd
}

if (options.replace) {
const remove = spawnSync('codex', ['mcp', 'remove', registration.codexName], {
cwd: process.cwd(),
encoding: 'utf8'
});
if (remove.status === 0) {
const remove = await execCodexCommand(['mcp', 'remove', registration.codexName]);
if (remove.exitCode === 0) {
console.log(chalk.gray(`Removed existing Codex MCP entry: ${registration.codexName}`));
}
}

const add = spawnSync('codex', ['mcp', 'add', registration.codexName, '--url', registration.url], {
cwd: process.cwd(),
encoding: 'utf8'
});
const add = await execCodexCommand(['mcp', 'add', registration.codexName, '--url', registration.url]);

if (add.status !== 0) {
if (add.exitCode !== 0) {
const stderr = add.stderr?.trim();
if (stderr?.includes('already exists')) {
console.log(chalk.yellow(`⚠️ Codex MCP entry already exists: ${registration.codexName}`));
Expand Down
16 changes: 13 additions & 3 deletions src/env/service-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ import { createConnection } from 'net';
import { setTimeout as sleep } from 'timers/promises';
import { Logger } from '../core/logger.js';

export class ServiceManagerError extends Error {
constructor(message: string, cause?: Error) {
super(message);
this.name = 'ServiceManagerError';
if (cause) {
this.cause = cause;
}
}
}

export type FilesystemAccessMode = 'read-only' | 'controlled-write';

export interface ServiceProfile {
Expand Down Expand Up @@ -155,7 +165,7 @@ class ServiceManager {
const envMode = process.env.CODEX_MCP_FILESYSTEM_MODE;
const requested = options?.filesystemMode || (envMode as FilesystemAccessMode | undefined) || 'read-only';
if (requested !== 'read-only' && requested !== 'controlled-write') {
throw new Error('Filesystem mode must be read-only or controlled-write.');
throw new ServiceManagerError('Filesystem mode must be read-only or controlled-write.');
}
return requested;
}
Expand All @@ -167,7 +177,7 @@ class ServiceManager {
const mode = this.resolveFilesystemMode(options);
const allowWrite = options?.allowFilesystemWrite === true || process.env.CODEX_MCP_FILESYSTEM_ALLOW_WRITE === '1';
if (mode === 'controlled-write' && !allowWrite) {
throw new Error(
throw new ServiceManagerError(
'controlled-write filesystem mode requires explicit approval. ' +
'Pass --allow-filesystem-write or set CODEX_MCP_FILESYSTEM_ALLOW_WRITE=1.'
);
Expand Down Expand Up @@ -225,7 +235,7 @@ class ServiceManager {
}

const healthy = await this.probeService(profile);
if (!healthy) {
if (healthy === false) {
diagnostics.push('Service process is running but health probe failed.');
}

Expand Down
51 changes: 43 additions & 8 deletions src/tui/app.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { FC } from 'react';
import type { InterfaceTier } from '../core/config.js';
import { Logger } from '../core/logger.js';

export interface TuiRuntimeSnapshot {
source: 'local' | 'daemon';
Expand Down Expand Up @@ -61,9 +62,33 @@ export interface TuiSnapshotProvider {
}

interface InkBindings {
Box: React.ComponentType<any>;
Text: React.ComponentType<any>;
useInput: (handler: (input: string, key: any) => void) => void;
Box: React.ComponentType<{
flexDirection?: 'row' | 'column' | 'row-reverse' | 'column-reverse';
padding?: number;
marginTop?: number;
// Allow additional Ink Box props not explicitly typed here
[key: string]: any;
}>;
Text: React.ComponentType<{
color?: string;
// Allow additional Ink Text props not explicitly typed here
[key: string]: any;
}>;
useInput: (handler: (input: string, key: {
upArrow: boolean;
downArrow: boolean;
leftArrow: boolean;
rightArrow: boolean;
return: boolean;
escape: boolean;
ctrl: boolean;
shift: boolean;
tab: boolean;
backspace: boolean;
delete: boolean;
// Allow additional key properties from Ink's Key type
[key: string]: boolean;
}) => void) => void;
useApp: () => { exit: () => void };
}

Expand Down Expand Up @@ -244,16 +269,26 @@ function renderFallbackMessage(provider: TuiSnapshotProvider): void {

export async function startTui(options: StartTuiOptions): Promise<void> {
const { provider, onExit, initialTier } = options;
const logger = Logger.getInstance();

let inkModule: any;
try {
inkModule = await import('ink');
} catch {
renderFallbackMessage(provider);
const snapshot = await provider.fetchSnapshot();
console.log(`Runtime ready: ${snapshot.status.initialized ? 'yes' : 'no'}`);
console.log(`Agents: ${snapshot.telemetry.agents.total}`);
onExit?.();
logger.info('tui', 'TUI dependencies are not installed. Falling back to snapshot mode.', {
source: provider.sourceLabel
});
logger.info('tui', 'Install with: npm install ink');

try {
const snapshot = await provider.fetchSnapshot();
logger.info('tui', `Runtime ready: ${snapshot.status.initialized ? 'yes' : 'no'}`);
logger.info('tui', `Agents: ${snapshot.telemetry.agents.total}`);
} catch (error) {
logger.error('tui', 'Failed to fetch snapshot in fallback mode', error as Error);
} finally {
onExit?.();
}
return;
}

Expand Down