Skip to content
Open
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
3 changes: 2 additions & 1 deletion cli/src/api/apiMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export class ApiMachineClient {

setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void {
this.rpcHandlerManager.registerHandler('spawn-happy-session', async (params: any) => {
const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, token, sessionType, worktreeName } = params || {}
const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, serviceTier, yolo, token, sessionType, worktreeName } = params || {}

if (!directory) {
throw new Error('Directory is required')
Expand All @@ -118,6 +118,7 @@ export class ApiMachineClient {
model,
effort,
modelReasoningEffort,
serviceTier,
yolo,
token,
sessionType,
Expand Down
4 changes: 4 additions & 0 deletions cli/src/codex/appServerTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface InitializeResponse {
export interface ThreadStartParams {
model?: string;
modelProvider?: string;
serviceTier?: ServiceTier;
cwd?: string;
approvalPolicy?: ApprovalPolicy;
sandbox?: SandboxMode;
Expand Down Expand Up @@ -49,6 +50,7 @@ export interface ThreadResumeParams {
path?: string;
model?: string;
modelProvider?: string;
serviceTier?: ServiceTier;
cwd?: string;
approvalPolicy?: ApprovalPolicy;
sandbox?: SandboxMode;
Expand Down Expand Up @@ -103,6 +105,7 @@ export type SandboxPolicy =

export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
export type ReasoningSummary = 'auto' | 'none' | 'brief' | 'detailed';
export type ServiceTier = 'fast' | 'flex';

export type CollaborationMode = {
mode: 'plan' | 'default';
Expand All @@ -120,6 +123,7 @@ export interface TurnStartParams {
approvalPolicy?: ApprovalPolicy;
sandboxPolicy?: SandboxPolicy;
model?: string;
serviceTier?: ServiceTier;
effort?: ReasoningEffort;
summary?: ReasoningSummary;
personality?: string;
Expand Down
14 changes: 11 additions & 3 deletions cli/src/codex/codexRemoteLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,7 +673,12 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
allowAnonymousTerminalEvent = true;
}
} catch (error) {
logger.warn('Error in codex session:', error);
const errorMessage = error instanceof Error
? error.message
: typeof error === 'string'
? error
: JSON.stringify(error);
logger.warn(`[Codex] Error in codex session: ${errorMessage}`);
const isAbortError = error instanceof Error && error.name === 'AbortError';
turnInFlight = false;
allowAnonymousTerminalEvent = false;
Expand All @@ -683,8 +688,11 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
messageBuffer.addMessage('Aborted by user', 'status');
session.sendSessionEvent({ type: 'message', message: 'Aborted by user' });
} else {
messageBuffer.addMessage('Process exited unexpectedly', 'status');
session.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' });
const sessionErrorMessage = errorMessage
? `Codex error: ${errorMessage}`
: 'Process exited unexpectedly';
messageBuffer.addMessage(sessionErrorMessage, 'status');
session.sendSessionEvent({ type: 'message', message: sessionErrorMessage });
this.currentTurnId = null;
this.currentThreadId = null;
hasThread = false;
Expand Down
3 changes: 2 additions & 1 deletion cli/src/codex/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { codexLocalLauncher } from './codexLocalLauncher';
import { codexRemoteLauncher } from './codexRemoteLauncher';
import { ApiClient, ApiSessionClient } from '@/lib';
import type { CodexCliOverrides } from './utils/codexCliOverrides';
import type { ReasoningEffort } from './appServerTypes';
import type { ReasoningEffort, ServiceTier } from './appServerTypes';
import type { CodexCollaborationMode, CodexPermissionMode } from '@hapi/protocol/types';

export type PermissionMode = CodexPermissionMode;
Expand All @@ -16,6 +16,7 @@ export interface EnhancedMode {
model?: string;
collaborationMode: CodexCollaborationMode;
modelReasoningEffort?: ReasoningEffort;
serviceTier?: ServiceTier;
}

interface LoopOptions {
Expand Down
10 changes: 8 additions & 2 deletions cli/src/codex/runCodex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { isPermissionModeAllowedForFlavor } from '@hapi/protocol';
import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas';
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
import { getInvokedCwd } from '@/utils/invokedCwd';
import type { ReasoningEffort } from './appServerTypes';
import type { ReasoningEffort, ServiceTier } from './appServerTypes';

export { emitReadyIfIdle } from './utils/emitReadyIfIdle';

Expand All @@ -23,6 +23,7 @@ export async function runCodex(opts: {
resumeSessionId?: string;
model?: string;
modelReasoningEffort?: ReasoningEffort;
serviceTier?: ServiceTier;
}): Promise<void> {
const workingDirectory = getInvokedCwd();
const startedBy = opts.startedBy ?? 'terminal';
Expand All @@ -48,6 +49,7 @@ export async function runCodex(opts: {
permissionMode: mode.permissionMode,
model: mode.model,
modelReasoningEffort: mode.modelReasoningEffort,
serviceTier: mode.serviceTier,
collaborationMode: mode.collaborationMode
}));

Expand All @@ -57,6 +59,7 @@ export async function runCodex(opts: {
let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default';
let currentModel = opts.model;
const currentModelReasoningEffort = opts.modelReasoningEffort;
const currentServiceTier = opts.serviceTier;
let currentCollaborationMode: EnhancedMode['collaborationMode'] = 'default';

const lifecycle = createRunnerLifecycle({
Expand All @@ -83,7 +86,8 @@ export async function runCodex(opts: {
logger.debug(
`[Codex] Synced session config for keepalive: ` +
`permissionMode=${currentPermissionMode}, model=${currentModel ?? 'auto'}, ` +
`modelReasoningEffort=${currentModelReasoningEffort ?? 'default'}, collaborationMode=${currentCollaborationMode}`
`modelReasoningEffort=${currentModelReasoningEffort ?? 'default'}, ` +
`serviceTier=${currentServiceTier ?? 'default'}, collaborationMode=${currentCollaborationMode}`
);
};

Expand All @@ -105,13 +109,15 @@ export async function runCodex(opts: {
logger.debug(
`[Codex] User message received with permission mode: ${currentPermissionMode}, ` +
`model: ${currentModel ?? 'auto'}, modelReasoningEffort: ${currentModelReasoningEffort ?? 'default'}, ` +
`serviceTier: ${currentServiceTier ?? 'default'}, ` +
`collaborationMode: ${currentCollaborationMode}`
);

const enhancedMode: EnhancedMode = {
permissionMode: messagePermissionMode ?? 'default',
model: currentModel,
modelReasoningEffort: currentModelReasoningEffort,
serviceTier: currentServiceTier,
collaborationMode: currentCollaborationMode
};
const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments);
Expand Down
26 changes: 26 additions & 0 deletions cli/src/codex/utils/appServerConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ describe('appServerConfig', () => {
});
});

it('passes service tier via thread params', () => {
const params = buildThreadStartParams({
cwd: '/workspace/project',
mode: { permissionMode: 'default', serviceTier: 'fast', collaborationMode: 'default' },
mcpServers
});

expect(params.serviceTier).toBe('fast');
});

it('builds turn params with mode defaults', () => {
const params = buildTurnStartParams({
threadId: 'thread-1',
Expand Down Expand Up @@ -126,6 +136,22 @@ describe('appServerConfig', () => {
expect(params.model).toBeUndefined();
});

it('passes service tier via turn params', () => {
const params = buildTurnStartParams({
threadId: 'thread-1',
message: 'hello',
cwd: '/workspace/project',
mode: {
permissionMode: 'default',
model: 'o3',
serviceTier: 'fast',
collaborationMode: 'default'
}
});

expect(params.serviceTier).toBe('fast');
});

it('puts collaboration mode in turn params with model settings', () => {
const params = buildTurnStartParams({
threadId: 'thread-1',
Expand Down
4 changes: 4 additions & 0 deletions cli/src/codex/utils/appServerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export function buildThreadStartParams(args: {
sandbox: resolvedSandbox,
baseInstructions,
developerInstructions: resolvedDeveloperInstructions,
...(args.mode.serviceTier ? { serviceTier: args.mode.serviceTier } : {}),
...(Object.keys(configWithInstructions).length > 0 ? { config: configWithInstructions } : {})
};

Expand Down Expand Up @@ -159,6 +160,9 @@ export function buildTurnStartParams(args: {
} else if (model) {
params.model = model;
}
if (args.mode?.serviceTier) {
params.serviceTier = args.mode.serviceTier;
}

return params;
}
20 changes: 19 additions & 1 deletion cli/src/commands/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { initializeToken } from '@/ui/tokenInit'
import { maybeAutoStartServer } from '@/utils/autoStartServer'
import type { CommandDefinition } from './types'
import type { CodexPermissionMode } from '@hapi/protocol/types'
import type { ReasoningEffort } from '@/codex/appServerTypes'
import type { ReasoningEffort, ServiceTier } from '@/codex/appServerTypes'

function parseReasoningEffort(value: string): ReasoningEffort {
switch (value) {
Expand All @@ -20,6 +20,17 @@ function parseReasoningEffort(value: string): ReasoningEffort {
}
}

function parseServiceTier(value: string): ServiceTier {
switch (value) {
case 'fast':
return 'fast'
case 'flex':
return 'flex'
default:
throw new Error('Invalid --service-tier value')
}
}

export const codexCommand: CommandDefinition = {
name: 'codex',
requiresRuntimeAssets: true,
Expand All @@ -34,6 +45,7 @@ export const codexCommand: CommandDefinition = {
resumeSessionId?: string
model?: string
modelReasoningEffort?: ReasoningEffort
serviceTier?: ServiceTier
} = {}
const unknownArgs: string[] = []

Expand Down Expand Up @@ -66,6 +78,12 @@ export const codexCommand: CommandDefinition = {
throw new Error('Missing --model-reasoning-effort value')
}
options.modelReasoningEffort = parseReasoningEffort(effort)
} else if (arg === '--service-tier') {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] --service-tier is swallowed in local/terminal mode.

This new branch parses the flag into options.serviceTier, but it never adds the original CLI args back into unknownArgs. The local launcher only forwards session.codexArgs to the native codex process (cli/src/codex/codexLocalLauncher.ts:18-49), so hapi codex --service-tier fast will silently start at the default tier.

Suggested fix:

} else if (arg === '--service-tier') {
    const serviceTier = commandArgs[++i]
    if (!serviceTier) {
        throw new Error('Missing --service-tier value')
    }
    options.serviceTier = parseServiceTier(serviceTier)
    unknownArgs.push('--service-tier', serviceTier)
}

const serviceTier = commandArgs[++i]
if (!serviceTier) {
throw new Error('Missing --service-tier value')
}
options.serviceTier = parseServiceTier(serviceTier)
} else {
unknownArgs.push(arg)
}
Expand Down
1 change: 1 addition & 0 deletions cli/src/modules/common/rpcTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface SpawnSessionOptions {
model?: string
effort?: string
modelReasoningEffort?: string
serviceTier?: string
yolo?: boolean
token?: string
sessionType?: 'simple' | 'worktree'
Expand Down
3 changes: 3 additions & 0 deletions cli/src/runner/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,9 @@ export async function startRunner(): Promise<void> {
if (options.modelReasoningEffort && agent === 'codex') {
args.push('--model-reasoning-effort', options.modelReasoningEffort);
}
if (options.serviceTier && agent === 'codex') {
args.push('--service-tier', options.serviceTier);
}
if (yolo) {
args.push('--yolo');
}
Expand Down
3 changes: 2 additions & 1 deletion hub/src/sync/rpcGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export class RpcGateway {
agent: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode' = 'claude',
model?: string,
modelReasoningEffort?: string,
serviceTier?: string,
yolo?: boolean,
sessionType?: 'simple' | 'worktree',
worktreeName?: string,
Expand All @@ -121,7 +122,7 @@ export class RpcGateway {
const result = await this.machineRpc(
machineId,
'spawn-happy-session',
{ type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort }
{ type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, serviceTier, yolo, sessionType, worktreeName, resumeSessionId, effort }
)
if (result && typeof result === 'object') {
const obj = result as Record<string, unknown>
Expand Down
2 changes: 2 additions & 0 deletions hub/src/sync/syncEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ export class SyncEngine {
agent: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode' = 'claude',
model?: string,
modelReasoningEffort?: string,
serviceTier?: string,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] Fast mode is wired for initial spawn only; inactive-session resume still drops it.

spawnSession() now accepts serviceTier, but resumeSession() still calls rpcGateway.spawnSession() with undefined for both modelReasoningEffort and serviceTier. Since the persisted session shape also lacks fields for those values (shared/src/schemas.ts:161-180), a resumed Codex session will come back on the default tier instead of the tier selected at creation time.

Suggested fix:

const spawnResult = await this.rpcGateway.spawnSession(
    targetMachine.id,
    metadata.path,
    flavor,
    session.model ?? undefined,
    session.modelReasoningEffort ?? undefined,
    session.serviceTier ?? undefined,
    undefined,
    undefined,
    resumeToken,
    session.effort ?? undefined
)

Persist modelReasoningEffort/serviceTier alongside the existing session model settings before replaying them here.

yolo?: boolean,
sessionType?: 'simple' | 'worktree',
worktreeName?: string,
Expand All @@ -333,6 +334,7 @@ export class SyncEngine {
agent,
model,
modelReasoningEffort,
serviceTier,
yolo,
sessionType,
worktreeName,
Expand Down
2 changes: 2 additions & 0 deletions hub/src/web/routes/machines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const spawnBodySchema = z.object({
model: z.string().optional(),
effort: z.string().optional(),
modelReasoningEffort: z.string().optional(),
serviceTier: z.string().optional(),
yolo: z.boolean().optional(),
sessionType: z.enum(['simple', 'worktree']).optional(),
worktreeName: z.string().optional()
Expand Down Expand Up @@ -57,6 +58,7 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho
parsed.data.agent,
parsed.data.model,
parsed.data.modelReasoningEffort,
parsed.data.serviceTier,
parsed.data.yolo,
parsed.data.sessionType,
parsed.data.worktreeName,
Expand Down
3 changes: 2 additions & 1 deletion web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,14 +389,15 @@ export class ApiClient {
agent?: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode',
model?: string,
modelReasoningEffort?: string,
serviceTier?: string,
yolo?: boolean,
sessionType?: 'simple' | 'worktree',
worktreeName?: string,
effort?: string
): Promise<SpawnResponse> {
return await this.request<SpawnResponse>(`/api/machines/${encodeURIComponent(machineId)}/spawn`, {
method: 'POST',
body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, effort })
body: JSON.stringify({ directory, agent, model, modelReasoningEffort, serviceTier, yolo, sessionType, worktreeName, effort })
})
}

Expand Down
47 changes: 47 additions & 0 deletions web/src/components/NewSession/FastModeToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { AgentType, CodexServiceTier } from './types'
import { CODEX_FAST_SERVICE_TIER } from './types'
import { useTranslation } from '@/lib/use-translation'

export function FastModeToggle(props: {
agent: AgentType
serviceTier: CodexServiceTier
isDisabled: boolean
onToggle: (value: CodexServiceTier) => void
}) {
const { t } = useTranslation()

if (props.agent !== 'codex') {
return null
}

const isEnabled = props.serviceTier === CODEX_FAST_SERVICE_TIER

return (
<div className="flex flex-col gap-1.5 px-3 py-3">
<label className="text-xs font-medium text-[var(--app-hint)]">
{t('newSession.fastMode')}
</label>
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col">
<span className="text-sm text-[var(--app-fg)]">
{t('newSession.fastMode.title')}
</span>
<span className="text-xs text-[var(--app-hint)]">
{t('newSession.fastMode.desc')}
</span>
</div>
<label className="relative inline-flex h-5 w-9 items-center">
<input
type="checkbox"
checked={isEnabled}
onChange={(e) => props.onToggle(e.target.checked ? CODEX_FAST_SERVICE_TIER : 'default')}
disabled={props.isDisabled}
className="peer sr-only"
/>
<span className="absolute inset-0 rounded-full bg-[var(--app-border)] transition-colors peer-checked:bg-[var(--app-link)] peer-disabled:opacity-50" />
<span className="absolute left-0.5 h-4 w-4 rounded-full bg-[var(--app-bg)] transition-transform peer-checked:translate-x-4 peer-disabled:opacity-50" />
</label>
</div>
</div>
)
}
Loading
Loading