From 16829b7c78215bf9fc94b6e6df8ea76b26dd7b2e Mon Sep 17 00:00:00 2001 From: weishu Date: Mon, 16 Mar 2026 20:48:39 +0800 Subject: [PATCH] Add support for codex plan mode --- cli/src/agent/sessionBase.ts | 23 +++- cli/src/agent/sessionFactory.ts | 8 +- cli/src/api/apiSession.ts | 7 +- cli/src/api/types.ts | 8 +- cli/src/codex/appServerTypes.ts | 15 ++- cli/src/codex/codexRemoteLauncher.test.ts | 42 +++++-- cli/src/codex/codexRemoteLauncher.ts | 25 +++- cli/src/codex/loop.ts | 11 +- cli/src/codex/runCodex.ts | 57 +++++++-- cli/src/codex/session.ts | 17 ++- cli/src/codex/utils/appServerConfig.test.ts | 81 +++++++++++-- cli/src/codex/utils/appServerConfig.ts | 39 ++++-- cli/src/codex/utils/codexStartConfig.test.ts | 8 +- hub/src/socket/handlers/cli/index.ts | 3 +- .../socket/handlers/cli/sessionHandlers.ts | 3 +- hub/src/sync/rpcGateway.ts | 3 +- hub/src/sync/sessionCache.ts | 29 ++++- hub/src/sync/sessionModel.test.ts | 25 ++++ hub/src/sync/syncEngine.ts | 12 +- hub/src/web/routes/sessions.test.ts | 112 ++++++++++++++++++ hub/src/web/routes/sessions.ts | 43 ++++++- shared/src/modes.ts | 24 ++++ shared/src/schemas.ts | 7 +- shared/src/socket.ts | 3 +- shared/src/types.ts | 2 + web/src/api/client.ts | 8 ++ .../AssistantChat/HappyComposer.tsx | 73 +++++++++++- .../components/AssistantChat/StatusBar.tsx | 33 ++++-- web/src/components/SessionChat.tsx | 29 ++++- web/src/hooks/mutations/useSessionActions.ts | 24 +++- web/src/hooks/useSSE.ts | 8 +- web/src/lib/locales/en.ts | 1 + web/src/lib/locales/zh-CN.ts | 1 + web/src/types/api.ts | 1 + 34 files changed, 688 insertions(+), 97 deletions(-) create mode 100644 hub/src/web/routes/sessions.test.ts diff --git a/cli/src/agent/sessionBase.ts b/cli/src/agent/sessionBase.ts index 49c7b193..9a4b36b5 100644 --- a/cli/src/agent/sessionBase.ts +++ b/cli/src/agent/sessionBase.ts @@ -1,6 +1,6 @@ import { ApiClient, ApiSessionClient } from '@/lib'; import { MessageQueue2 } from '@/utils/MessageQueue2'; -import type { Metadata, SessionModel, SessionPermissionMode } from '@/api/types'; +import type { Metadata, SessionCollaborationMode, SessionModel, SessionPermissionMode } from '@/api/types'; import { logger } from '@/ui/logger'; export type AgentSessionBaseOptions = { @@ -17,6 +17,7 @@ export type AgentSessionBaseOptions = { applySessionIdToMetadata: (metadata: Metadata, sessionId: string) => Metadata; permissionMode?: SessionPermissionMode; model?: SessionModel; + collaborationMode?: SessionCollaborationMode; }; export class AgentSessionBase { @@ -38,6 +39,7 @@ export class AgentSessionBase { private keepAliveInterval: NodeJS.Timeout | null = null; protected permissionMode?: SessionPermissionMode; protected model?: SessionModel; + protected collaborationMode?: SessionCollaborationMode; constructor(opts: AgentSessionBaseOptions) { this.path = opts.path; @@ -53,6 +55,7 @@ export class AgentSessionBase { this.mode = opts.mode ?? 'local'; this.permissionMode = opts.permissionMode; this.model = opts.model; + this.collaborationMode = opts.collaborationMode; this.client.keepAlive(this.thinking, this.mode, this.getKeepAliveRuntime()); this.keepAliveInterval = setInterval(() => { @@ -71,7 +74,11 @@ export class AgentSessionBase { this.client.keepAlive(this.thinking, mode, this.getKeepAliveRuntime()); const permissionLabel = this.permissionMode ?? 'unset'; const modelLabel = this.model === undefined ? 'unset' : (this.model ?? 'auto'); - logger.debug(`[${this.sessionLabel}] Mode switched to ${mode} (permissionMode=${permissionLabel}, model=${modelLabel})`); + const collaborationLabel = this.collaborationMode ?? 'unset'; + logger.debug( + `[${this.sessionLabel}] Mode switched to ${mode} ` + + `(permissionMode=${permissionLabel}, model=${modelLabel}, collaborationMode=${collaborationLabel})` + ); this._onModeChange(mode); }; @@ -103,13 +110,15 @@ export class AgentSessionBase { } }; - protected getKeepAliveRuntime(): { permissionMode?: SessionPermissionMode; model?: SessionModel } | undefined { - if (this.permissionMode === undefined && this.model === undefined) { + protected getKeepAliveRuntime(): + { permissionMode?: SessionPermissionMode; model?: SessionModel; collaborationMode?: SessionCollaborationMode } | undefined { + if (this.permissionMode === undefined && this.model === undefined && this.collaborationMode === undefined) { return undefined; } return { permissionMode: this.permissionMode, - model: this.model + model: this.model, + collaborationMode: this.collaborationMode }; } @@ -120,4 +129,8 @@ export class AgentSessionBase { getModel(): SessionModel | undefined { return this.model; } + + getCollaborationMode(): SessionCollaborationMode | undefined { + return this.collaborationMode; + } } diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index 43e06a76..e9b3f591 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -22,6 +22,7 @@ export type SessionBootstrapOptions = { tag?: string agentState?: AgentState | null model?: string + metadataOverrides?: Partial } export type SessionBootstrapResult = { @@ -51,6 +52,7 @@ export function buildSessionMetadata(options: { workingDirectory: string machineId: string now?: number + metadataOverrides?: Partial }): Metadata { const happyLibDir = runtimePath() const worktreeInfo = readWorktreeEnv() @@ -72,7 +74,8 @@ export function buildSessionMetadata(options: { lifecycleState: 'running', lifecycleStateSince: now, flavor: options.flavor, - worktree: worktreeInfo ?? undefined + worktree: worktreeInfo ?? undefined, + ...options.metadataOverrides } } @@ -119,7 +122,8 @@ export async function bootstrapSession(options: SessionBootstrapOptions): Promis flavor: options.flavor, startedBy, workingDirectory, - machineId + machineId, + metadataOverrides: options.metadataOverrides }) const sessionInfo = await api.getOrCreateSession({ diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 6d3b29cc..549c6e1e 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -21,6 +21,7 @@ import type { MessageContent, MessageMeta, Metadata, + SessionCollaborationMode, Session, SessionModel, SessionPermissionMode, @@ -438,7 +439,11 @@ export class ApiSessionClient extends EventEmitter { keepAlive( thinking: boolean, mode: 'local' | 'remote', - runtime?: { permissionMode?: SessionPermissionMode; model?: SessionModel } + runtime?: { + permissionMode?: SessionPermissionMode + model?: SessionModel + collaborationMode?: SessionCollaborationMode + } ): void { this.socket.volatile.emit('session-alive', { sid: this.sessionId, diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index 553b635a..b5afb718 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -1,11 +1,12 @@ import { AgentStateSchema, AttachmentMetadataSchema, + CodexCollaborationModeSchema, MetadataSchema, PermissionModeSchema, TodosSchema } from '@hapi/protocol/schemas' -import type { PermissionMode } from '@hapi/protocol/types' +import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' import { z } from 'zod' import { UsageSchema } from '@/claude/types' @@ -15,11 +16,13 @@ export type { AgentState, AttachmentMetadata, ClaudePermissionMode, + CodexCollaborationMode, CodexPermissionMode, Metadata, Session } from '@hapi/protocol/types' export type SessionPermissionMode = PermissionMode +export type SessionCollaborationMode = CodexCollaborationMode export type SessionModel = string | null export { AgentStateSchema, AttachmentMetadataSchema, MetadataSchema } @@ -96,7 +99,8 @@ export const CreateSessionResponseSchema = z.object({ thinkingAt: z.number(), todos: TodosSchema.optional(), model: z.string().nullable(), - permissionMode: PermissionModeSchema.optional() + permissionMode: PermissionModeSchema.optional(), + collaborationMode: CodexCollaborationModeSchema.optional() }) }) diff --git a/cli/src/codex/appServerTypes.ts b/cli/src/codex/appServerTypes.ts index 7af0c517..acc1b951 100644 --- a/cli/src/codex/appServerTypes.ts +++ b/cli/src/codex/appServerTypes.ts @@ -1,12 +1,17 @@ export type ApprovalPolicy = 'untrusted' | 'on-failure' | 'on-request' | 'never'; export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'; +export interface InitializeCapabilities { + experimentalApi: boolean; +} + export interface InitializeParams { clientInfo: { name: string; title?: string; version: string; }; + capabilities: InitializeCapabilities | null; } export interface InitializeResponse { @@ -32,6 +37,7 @@ export interface ThreadStartResponse { thread: { id: string; }; + model: string; [key: string]: unknown; } @@ -56,6 +62,7 @@ export interface ThreadResumeResponse { thread: { id: string; }; + model: string; [key: string]: unknown; } @@ -98,8 +105,12 @@ export type ReasoningEffort = 'low' | 'medium' | 'high' | 'auto'; export type ReasoningSummary = 'auto' | 'none' | 'brief' | 'detailed'; export type CollaborationMode = { - mode: 'plan' | 'code' | 'pair_programming' | 'execute' | 'custom' | (string & {}); - settings?: Record; + mode: 'plan' | 'default'; + settings: { + model: string; + reasoning_effort?: ReasoningEffort | null; + developer_instructions?: string | null; + }; }; export interface TurnStartParams { diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 5d3ca2eb..6d9d6fe6 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -4,7 +4,8 @@ import type { EnhancedMode } from './loop'; const harness = vi.hoisted(() => ({ notifications: [] as Array<{ method: string; params: unknown }>, - registerRequestCalls: [] as string[] + registerRequestCalls: [] as string[], + initializeCalls: [] as unknown[] })); vi.mock('./codexAppServerClient', () => { @@ -13,7 +14,8 @@ vi.mock('./codexAppServerClient', () => { async connect(): Promise {} - async initialize(): Promise<{ protocolVersion: number }> { + async initialize(params: unknown): Promise<{ protocolVersion: number }> { + harness.initializeCalls.push(params); return { protocolVersion: 1 }; } @@ -25,12 +27,12 @@ vi.mock('./codexAppServerClient', () => { harness.registerRequestCalls.push(method); } - async startThread(): Promise<{ thread: { id: string } }> { - return { thread: { id: 'thread-anonymous' } }; + async startThread(): Promise<{ thread: { id: string }; model: string }> { + return { thread: { id: 'thread-anonymous' }, model: 'gpt-5.4' }; } - async resumeThread(): Promise<{ thread: { id: string } }> { - return { thread: { id: 'thread-anonymous' } }; + async resumeThread(): Promise<{ thread: { id: string }; model: string }> { + return { thread: { id: 'thread-anonymous' }, model: 'gpt-5.4' }; } async startTurn(): Promise<{ turn: Record }> { @@ -73,7 +75,8 @@ type FakeAgentState = { function createMode(): EnhancedMode { return { - permissionMode: 'default' + permissionMode: 'default', + collaborationMode: 'default' }; } @@ -86,6 +89,7 @@ function createSessionStub() { const codexMessages: unknown[] = []; const thinkingChanges: boolean[] = []; const foundSessionIds: string[] = []; + let currentModel: string | null | undefined; let agentState: FakeAgentState = { requests: {}, completedRequests: {} @@ -119,6 +123,15 @@ function createSessionStub() { codexCliOverrides: undefined, sessionId: null as string | null, thinking: false, + getPermissionMode() { + return 'default' as const; + }, + setModel(nextModel: string | null) { + currentModel = nextModel; + }, + getModel() { + return currentModel; + }, onThinkingChange(nextThinking: boolean) { session.thinking = nextThinking; thinkingChanges.push(nextThinking); @@ -145,6 +158,7 @@ function createSessionStub() { thinkingChanges, foundSessionIds, rpcHandlers, + getModel: () => currentModel, getAgentState: () => agentState }; } @@ -153,6 +167,7 @@ describe('codexRemoteLauncher', () => { afterEach(() => { harness.notifications = []; harness.registerRequestCalls = []; + harness.initializeCalls = []; delete process.env.CODEX_USE_MCP_SERVER; }); @@ -162,13 +177,24 @@ describe('codexRemoteLauncher', () => { session, sessionEvents, thinkingChanges, - foundSessionIds + foundSessionIds, + getModel } = createSessionStub(); const exitReason = await codexRemoteLauncher(session as never); expect(exitReason).toBe('exit'); expect(foundSessionIds).toContain('thread-anonymous'); + expect(getModel()).toBe('gpt-5.4'); + expect(harness.initializeCalls).toEqual([{ + clientInfo: { + name: 'hapi-codex-client', + version: '1.0.0' + }, + capabilities: { + experimentalApi: true + } + }]); expect(harness.notifications.map((entry) => entry.method)).toEqual(['turn/started', 'turn/completed']); expect(sessionEvents.filter((event) => event.type === 'ready').length).toBeGreaterThanOrEqual(1); expect(thinkingChanges).toContain(true); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 5dc2f27e..705809f1 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -156,6 +156,16 @@ class CodexRemoteLauncher extends RemoteLauncherBase { return typeof value === 'string' && value.length > 0 ? value : null; }; + const applyResolvedModel = (value: unknown): string | undefined => { + const resolvedModel = asString(value) ?? undefined; + if (!resolvedModel) { + return undefined; + } + session.setModel(resolvedModel); + logger.debug(`[Codex] Resolved app-server model: ${resolvedModel}`); + return resolvedModel; + }; + const buildMcpToolName = (server: unknown, tool: unknown): string | null => { const serverName = asString(server); const toolName = asString(tool); @@ -584,6 +594,9 @@ class CodexRemoteLauncher extends RemoteLauncherBase { clientInfo: { name: 'hapi-codex-client', version: '1.0.0' + }, + capabilities: { + experimentalApi: true } }); } else if (mcpClient) { @@ -680,6 +693,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const resumeRecord = asRecord(resumeResponse); const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null; threadId = asString(resumeThread?.id) ?? resumeCandidate; + applyResolvedModel(resumeRecord?.model); logger.debug(`[Codex] Resumed app-server thread ${threadId}`); } catch (error) { logger.warn(`[Codex] Failed to resume app-server thread ${resumeCandidate}, starting new thread`, error); @@ -693,6 +707,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const threadRecord = asRecord(threadResponse); const thread = threadRecord ? asRecord(threadRecord.thread) : null; threadId = asString(thread?.id); + applyResolvedModel(threadRecord?.model); if (!threadId) { throw new Error('app-server thread/start did not return thread.id'); } @@ -708,7 +723,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const turnParams = buildTurnStartParams({ threadId, message: message.message, - mode: message.mode, + mode: { + ...message.mode, + model: session.getModel() ?? message.mode.model + }, cliOverrides: session.codexCliOverrides }); turnInFlight = true; @@ -750,7 +768,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const turnParams = buildTurnStartParams({ threadId: this.currentThreadId, message: message.message, - mode: message.mode, + mode: { + ...message.mode, + model: session.getModel() ?? message.mode.model + }, cliOverrides: session.codexCliOverrides }); turnInFlight = true; diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index 51a5c7aa..bff31774 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -6,15 +6,14 @@ import { codexLocalLauncher } from './codexLocalLauncher'; import { codexRemoteLauncher } from './codexRemoteLauncher'; import { ApiClient, ApiSessionClient } from '@/lib'; import type { CodexCliOverrides } from './utils/codexCliOverrides'; -import type { CodexPermissionMode } from '@hapi/protocol/types'; -import type { CollaborationMode } from './appServerTypes'; +import type { CodexCollaborationMode, CodexPermissionMode } from '@hapi/protocol/types'; export type PermissionMode = CodexPermissionMode; export interface EnhancedMode { permissionMode: PermissionMode; model?: string; - collaborationMode?: CollaborationMode['mode']; + collaborationMode: CodexCollaborationMode; } interface LoopOptions { @@ -28,6 +27,8 @@ interface LoopOptions { codexArgs?: string[]; codexCliOverrides?: CodexCliOverrides; permissionMode?: PermissionMode; + model?: string; + collaborationMode?: CodexCollaborationMode; resumeSessionId?: string; onSessionReady?: (session: CodexSession) => void; } @@ -49,7 +50,9 @@ export async function loop(opts: LoopOptions): Promise { startingMode, codexArgs: opts.codexArgs, codexCliOverrides: opts.codexCliOverrides, - permissionMode: opts.permissionMode ?? 'default' + permissionMode: opts.permissionMode ?? 'default', + model: opts.model, + collaborationMode: opts.collaborationMode ?? 'default' }); await runLocalRemoteSession({ diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index c05b4bcd..4cbf4bc4 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -9,7 +9,7 @@ import { parseCodexCliOverrides } from './utils/codexCliOverrides'; import { bootstrapSession } from '@/agent/sessionFactory'; import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; -import { PermissionModeSchema } from '@hapi/protocol/schemas'; +import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; export { emitReadyIfIdle } from './utils/emitReadyIfIdle'; @@ -23,6 +23,7 @@ export async function runCodex(opts: { }): Promise { const workingDirectory = process.cwd(); const startedBy = opts.startedBy ?? 'terminal'; + const codexRemoteBackend = process.env.CODEX_USE_MCP_SERVER === '1' ? 'mcp-server' : 'app-server'; logger.debug(`[codex] Starting with options: startedBy=${startedBy}`); @@ -34,7 +35,10 @@ export async function runCodex(opts: { startedBy, workingDirectory, agentState: state, - model: opts.model + model: opts.model, + metadataOverrides: { + codexRemoteBackend + } }); const startingMode: 'local' | 'remote' = startedBy === 'runner' ? 'remote' : 'local'; @@ -51,8 +55,8 @@ export async function runCodex(opts: { const sessionWrapperRef: { current: CodexSession | null } = { current: null }; let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; - const currentModel = opts.model; - let currentCollaborationMode: EnhancedMode['collaborationMode']; + let currentModel = opts.model; + let currentCollaborationMode: EnhancedMode['collaborationMode'] = 'default'; const lifecycle = createRunnerLifecycle({ session, @@ -68,13 +72,38 @@ export async function runCodex(opts: { if (!sessionInstance) { return; } + const sessionModel = sessionInstance.getModel(); + if (sessionModel !== undefined) { + currentModel = sessionModel ?? undefined; + } sessionInstance.setPermissionMode(currentPermissionMode); - logger.debug(`[Codex] Synced session permission mode for keepalive: ${currentPermissionMode}`); + sessionInstance.setModel(currentModel ?? null); + sessionInstance.setCollaborationMode(currentCollaborationMode); + logger.debug( + `[Codex] Synced session config for keepalive: ` + + `permissionMode=${currentPermissionMode}, model=${currentModel ?? 'auto'}, collaborationMode=${currentCollaborationMode}` + ); }; session.onUserMessage((message) => { + const sessionPermissionMode = sessionWrapperRef.current?.getPermissionMode(); + if (sessionPermissionMode && isPermissionModeAllowedForFlavor(sessionPermissionMode, 'codex')) { + currentPermissionMode = sessionPermissionMode as PermissionMode; + } + const sessionModel = sessionWrapperRef.current?.getModel(); + if (sessionModel !== undefined) { + currentModel = sessionModel ?? undefined; + } + const sessionCollaborationMode = sessionWrapperRef.current?.getCollaborationMode(); + if (sessionCollaborationMode) { + currentCollaborationMode = sessionCollaborationMode; + } + const messagePermissionMode = currentPermissionMode; - logger.debug(`[Codex] User message received with permission mode: ${currentPermissionMode}`); + logger.debug( + `[Codex] User message received with permission mode: ${currentPermissionMode}, ` + + `model: ${currentModel ?? 'auto'}, collaborationMode: ${currentCollaborationMode}` + ); const enhancedMode: EnhancedMode = { permissionMode: messagePermissionMode ?? 'default', @@ -103,16 +132,13 @@ export async function runCodex(opts: { const resolveCollaborationMode = (value: unknown): EnhancedMode['collaborationMode'] => { if (value === null) { - return undefined; + return 'default'; } - if (typeof value !== 'string') { + const parsed = CodexCollaborationModeSchema.safeParse(value); + if (!parsed.success) { throw new Error('Invalid collaboration mode'); } - const trimmed = value.trim(); - if (!trimmed) { - throw new Error('Invalid collaboration mode'); - } - return trimmed as EnhancedMode['collaborationMode']; + return parsed.data; }; session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => { @@ -126,6 +152,9 @@ export async function runCodex(opts: { } if (config.collaborationMode !== undefined) { + if (codexRemoteBackend !== 'app-server') { + throw new Error('Collaboration mode is only supported for Codex app-server remote sessions'); + } currentCollaborationMode = resolveCollaborationMode(config.collaborationMode); } @@ -144,6 +173,8 @@ export async function runCodex(opts: { codexCliOverrides, startedBy, permissionMode: currentPermissionMode, + model: currentModel, + collaborationMode: currentCollaborationMode, resumeSessionId: opts.resumeSessionId, onModeChange: createModeChangeHandler(session), onSessionReady: (instance) => { diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index d32ba4fa..d54c2064 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -4,6 +4,7 @@ import { AgentSessionBase } from '@/agent/sessionBase'; import type { EnhancedMode, PermissionMode } from './loop'; import type { CodexCliOverrides } from './utils/codexCliOverrides'; import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; +import type { SessionModel } from '@/api/types'; type LocalLaunchFailure = { message: string; @@ -31,6 +32,8 @@ export class CodexSession extends AgentSessionBase { codexArgs?: string[]; codexCliOverrides?: CodexCliOverrides; permissionMode?: PermissionMode; + model?: SessionModel; + collaborationMode?: EnhancedMode['collaborationMode']; }) { super({ api: opts.api, @@ -47,7 +50,9 @@ export class CodexSession extends AgentSessionBase { ...metadata, codexSessionId: sessionId }), - permissionMode: opts.permissionMode + permissionMode: opts.permissionMode, + model: opts.model, + collaborationMode: opts.collaborationMode }); this.codexArgs = opts.codexArgs; @@ -55,12 +60,22 @@ export class CodexSession extends AgentSessionBase { this.startedBy = opts.startedBy; this.startingMode = opts.startingMode; this.permissionMode = opts.permissionMode; + this.model = opts.model; + this.collaborationMode = opts.collaborationMode; } setPermissionMode = (mode: PermissionMode): void => { this.permissionMode = mode; }; + setModel = (model: SessionModel): void => { + this.model = model; + }; + + setCollaborationMode = (mode: EnhancedMode['collaborationMode']): void => { + this.collaborationMode = mode; + }; + recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { this.localLaunchFailure = { message, exitReason }; }; diff --git a/cli/src/codex/utils/appServerConfig.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index a1f1bd43..7930728d 100644 --- a/cli/src/codex/utils/appServerConfig.test.ts +++ b/cli/src/codex/utils/appServerConfig.test.ts @@ -7,7 +7,7 @@ describe('appServerConfig', () => { it('applies CLI overrides when permission mode is default', () => { const params = buildThreadStartParams({ - mode: { permissionMode: 'default' }, + mode: { permissionMode: 'default', collaborationMode: 'default' }, mcpServers, cliOverrides: { sandbox: 'danger-full-access', approvalPolicy: 'never' } }); @@ -27,7 +27,7 @@ describe('appServerConfig', () => { it('ignores CLI overrides when permission mode is not default', () => { const params = buildThreadStartParams({ - mode: { permissionMode: 'yolo' }, + mode: { permissionMode: 'yolo', collaborationMode: 'default' }, mcpServers, cliOverrides: { sandbox: 'read-only', approvalPolicy: 'never' } }); @@ -38,7 +38,7 @@ describe('appServerConfig', () => { it('keeps on-failure approvals for safe-yolo threads', () => { const params = buildThreadStartParams({ - mode: { permissionMode: 'safe-yolo' }, + mode: { permissionMode: 'safe-yolo', collaborationMode: 'default' }, mcpServers }); @@ -48,7 +48,7 @@ describe('appServerConfig', () => { it('concatenates custom developer instructions after base instructions', () => { const params = buildThreadStartParams({ - mode: { permissionMode: 'default' }, + mode: { permissionMode: 'default', collaborationMode: 'default' }, mcpServers, developerInstructions: 'Only respond in Chinese.' }); @@ -68,14 +68,21 @@ describe('appServerConfig', () => { const params = buildTurnStartParams({ threadId: 'thread-1', message: 'hello', - mode: { permissionMode: 'read-only', model: 'o3' } + mode: { permissionMode: 'read-only', model: 'o3', collaborationMode: 'default' } }); expect(params.threadId).toBe('thread-1'); expect(params.input).toEqual([{ type: 'text', text: 'hello' }]); expect(params.approvalPolicy).toBe('never'); expect(params.sandboxPolicy).toEqual({ type: 'readOnly' }); - expect(params.model).toBe('o3'); + expect(params.collaborationMode).toEqual({ + mode: 'default', + settings: { + model: 'o3', + developer_instructions: codexSystemPrompt + } + }); + expect(params.model).toBeUndefined(); }); it('puts collaboration mode in turn params with model settings', () => { @@ -85,43 +92,95 @@ describe('appServerConfig', () => { mode: { permissionMode: 'default', model: 'o3', collaborationMode: 'plan' } }); - expect(params.collaborationMode).toEqual({ mode: 'plan', settings: { model: 'o3' } }); + expect(params.collaborationMode).toEqual({ + mode: 'plan', + settings: { + model: 'o3', + developer_instructions: codexSystemPrompt + } + }); expect(params.model).toBeUndefined(); }); + it('carries custom developer instructions into collaboration mode settings', () => { + const params = buildTurnStartParams({ + threadId: 'thread-1', + message: 'hello', + mode: { permissionMode: 'default', model: 'o3', collaborationMode: 'plan' }, + developerInstructions: 'Only respond in Chinese.' + }); + + expect(params.collaborationMode).toEqual({ + mode: 'plan', + settings: { + model: 'o3', + developer_instructions: `${codexSystemPrompt}\n\nOnly respond in Chinese.` + } + }); + }); + + it('rejects collaboration mode payloads without a resolved model', () => { + expect(() => buildTurnStartParams({ + threadId: 'thread-1', + message: 'hello', + mode: { permissionMode: 'default', collaborationMode: 'plan' } + })).toThrow("Collaboration mode 'plan' requires a resolved model"); + }); + it('applies CLI overrides for turns when permission mode is default', () => { const params = buildTurnStartParams({ threadId: 'thread-1', message: 'hello', - mode: { permissionMode: 'default' }, + mode: { permissionMode: 'default', model: 'o3', collaborationMode: 'default' }, cliOverrides: { sandbox: 'danger-full-access', approvalPolicy: 'never' } }); expect(params.approvalPolicy).toBe('never'); expect(params.sandboxPolicy).toEqual({ type: 'dangerFullAccess' }); + expect(params.collaborationMode).toEqual({ + mode: 'default', + settings: { + model: 'o3', + developer_instructions: codexSystemPrompt + } + }); }); it('ignores CLI overrides for turns when permission mode is not default', () => { const params = buildTurnStartParams({ threadId: 'thread-1', message: 'hello', - mode: { permissionMode: 'safe-yolo' }, + mode: { permissionMode: 'safe-yolo', model: 'o3', collaborationMode: 'default' }, cliOverrides: { sandbox: 'read-only', approvalPolicy: 'never' } }); expect(params.approvalPolicy).toBe('on-failure'); expect(params.sandboxPolicy).toEqual({ type: 'workspaceWrite' }); + expect(params.collaborationMode).toEqual({ + mode: 'default', + settings: { + model: 'o3', + developer_instructions: codexSystemPrompt + } + }); }); it('prefers turn overrides', () => { const params = buildTurnStartParams({ threadId: 'thread-1', message: 'hello', - mode: { permissionMode: 'default' }, + mode: { permissionMode: 'default', collaborationMode: 'default' }, overrides: { approvalPolicy: 'on-request', model: 'gpt-5' } }); expect(params.approvalPolicy).toBe('on-request'); - expect(params.model).toBe('gpt-5'); + expect(params.collaborationMode).toEqual({ + mode: 'default', + settings: { + model: 'gpt-5', + developer_instructions: codexSystemPrompt + } + }); + expect(params.model).toBeUndefined(); }); }); diff --git a/cli/src/codex/utils/appServerConfig.ts b/cli/src/codex/utils/appServerConfig.ts index 2630ab5e..00eb1165 100644 --- a/cli/src/codex/utils/appServerConfig.ts +++ b/cli/src/codex/utils/appServerConfig.ts @@ -49,6 +49,20 @@ function buildMcpServerConfig(mcpServers: McpServersConfig): Record { it('applies CLI overrides when permission mode is default', () => { const config = buildCodexStartConfig({ message: 'hello', - mode: { permissionMode: 'default' }, + mode: { permissionMode: 'default', collaborationMode: 'default' }, first: true, mcpServers, cliOverrides: { sandbox: 'danger-full-access', approvalPolicy: 'never' } @@ -25,7 +25,7 @@ describe('buildCodexStartConfig', () => { it('ignores CLI overrides when permission mode is not default', () => { const config = buildCodexStartConfig({ message: 'hello', - mode: { permissionMode: 'yolo' }, + mode: { permissionMode: 'yolo', collaborationMode: 'default' }, first: false, mcpServers, cliOverrides: { sandbox: 'read-only', approvalPolicy: 'never' } @@ -38,7 +38,7 @@ describe('buildCodexStartConfig', () => { it('keeps on-failure approvals for safe-yolo', () => { const config = buildCodexStartConfig({ message: 'hello', - mode: { permissionMode: 'safe-yolo' }, + mode: { permissionMode: 'safe-yolo', collaborationMode: 'default' }, first: false, mcpServers }); @@ -50,7 +50,7 @@ describe('buildCodexStartConfig', () => { it('passes model when provided', () => { const config = buildCodexStartConfig({ message: 'hello', - mode: { permissionMode: 'default', model: 'o3' }, + mode: { permissionMode: 'default', model: 'o3', collaborationMode: 'default' }, first: false, mcpServers }); diff --git a/hub/src/socket/handlers/cli/index.ts b/hub/src/socket/handlers/cli/index.ts index e44471e9..ca2e1d2f 100644 --- a/hub/src/socket/handlers/cli/index.ts +++ b/hub/src/socket/handlers/cli/index.ts @@ -1,4 +1,4 @@ -import type { PermissionMode } from '@hapi/protocol/types' +import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' import type { Store, StoredMachine, StoredSession } from '../../../store' import type { RpcRegistry } from '../../rpcRegistry' import type { SyncEvent } from '../../../sync/syncEngine' @@ -17,6 +17,7 @@ type SessionAlivePayload = { mode?: 'local' | 'remote' permissionMode?: PermissionMode model?: string | null + collaborationMode?: CodexCollaborationMode } type SessionEndPayload = { diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index f14fa27d..89c31c86 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -1,7 +1,7 @@ import type { ClientToServerEvents } from '@hapi/protocol' import { z } from 'zod' import { randomUUID } from 'node:crypto' -import type { PermissionMode } from '@hapi/protocol/types' +import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' import type { Store, StoredSession } from '../../../store' import type { SyncEvent } from '../../../sync/syncEngine' import { extractTodoWriteTodosFromMessageContent } from '../../../sync/todos' @@ -16,6 +16,7 @@ type SessionAlivePayload = { mode?: 'local' | 'remote' permissionMode?: PermissionMode model?: string | null + collaborationMode?: CodexCollaborationMode } type SessionEndPayload = { diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 70885965..ca508636 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -1,4 +1,4 @@ -import type { PermissionMode } from '@hapi/protocol/types' +import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' import type { Server } from 'socket.io' import type { RpcRegistry } from '../socket/rpcRegistry' @@ -94,6 +94,7 @@ export class RpcGateway { config: { permissionMode?: PermissionMode model?: string | null + collaborationMode?: CodexCollaborationMode } ): Promise { return await this.sessionRpc(sessionId, 'set-session-config', config) diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index d78b0167..4a8ab0c8 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -1,5 +1,5 @@ import { AgentStateSchema, MetadataSchema, TeamStateSchema } from '@hapi/protocol/schemas' -import type { PermissionMode, Session } from '@hapi/protocol/types' +import type { CodexCollaborationMode, PermissionMode, Session } from '@hapi/protocol/types' import type { Store } from '../store' import { clampAliveTime } from './aliveTime' import { EventPublisher } from './eventPublisher' @@ -127,7 +127,8 @@ export class SessionCache { todos, teamState, model: stored.model, - permissionMode: existing?.permissionMode + permissionMode: existing?.permissionMode, + collaborationMode: existing?.collaborationMode } this.sessions.set(sessionId, session) @@ -149,6 +150,7 @@ export class SessionCache { mode?: 'local' | 'remote' permissionMode?: PermissionMode model?: string | null + collaborationMode?: CodexCollaborationMode }): void { const t = clampAliveTime(payload.time) if (!t) return @@ -160,6 +162,7 @@ export class SessionCache { const wasThinking = session.thinking const previousPermissionMode = session.permissionMode const previousModel = session.model + const previousCollaborationMode = session.collaborationMode session.active = true session.activeAt = Math.max(session.activeAt, t) @@ -176,10 +179,15 @@ export class SessionCache { } session.model = payload.model } + if (payload.collaborationMode !== undefined) { + session.collaborationMode = payload.collaborationMode + } const now = Date.now() const lastBroadcastAt = this.lastBroadcastAtBySessionId.get(session.id) ?? 0 - const modeChanged = previousPermissionMode !== session.permissionMode || previousModel !== session.model + const modeChanged = previousPermissionMode !== session.permissionMode + || previousModel !== session.model + || previousCollaborationMode !== session.collaborationMode const shouldBroadcast = (!wasActive && session.active) || (wasThinking !== session.thinking) || modeChanged @@ -195,7 +203,8 @@ export class SessionCache { activeAt: session.activeAt, thinking: session.thinking, permissionMode: session.permissionMode, - model: session.model + model: session.model, + collaborationMode: session.collaborationMode } }) } @@ -230,7 +239,14 @@ export class SessionCache { } } - applySessionConfig(sessionId: string, config: { permissionMode?: PermissionMode; model?: string | null }): void { + applySessionConfig( + sessionId: string, + config: { + permissionMode?: PermissionMode + model?: string | null + collaborationMode?: CodexCollaborationMode + } + ): void { const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId) if (!session) { return @@ -250,6 +266,9 @@ export class SessionCache { } session.model = config.model } + if (config.collaborationMode !== undefined) { + session.collaborationMode = config.collaborationMode + } this.publisher.emit({ type: 'session-updated', sessionId, data: session }) } diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index a386d8ab..529cd1c9 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -104,6 +104,31 @@ describe('session model', () => { expect(store.sessions.getSession(session.id)?.model).toBeNull() }) + it('tracks collaboration mode updates in memory from config and keepalive', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-collaboration-mode', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default', + 'gpt-5.4' + ) + + cache.applySessionConfig(session.id, { collaborationMode: 'plan' }) + expect(cache.getSession(session.id)?.collaborationMode).toBe('plan') + + cache.handleSessionAlive({ + sid: session.id, + time: Date.now(), + thinking: false, + collaborationMode: 'default' + }) + expect(cache.getSession(session.id)?.collaborationMode).toBe('default') + }) + it('passes the stored model when respawning a resumed session', async () => { const store = new Store(':memory:') const engine = new SyncEngine( diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 7e049bac..063e441a 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -7,7 +7,7 @@ * - No E2E encryption; data is stored as JSON in SQLite */ -import type { DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' +import type { CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' import type { Server } from 'socket.io' import type { Store } from '../store' import type { RpcRegistry } from '../socket/rpcRegistry' @@ -188,6 +188,7 @@ export class SyncEngine { mode?: 'local' | 'remote' permissionMode?: PermissionMode model?: string | null + collaborationMode?: CodexCollaborationMode }): void { this.sessionCache.handleSessionAlive(payload) } @@ -282,13 +283,20 @@ export class SyncEngine { config: { permissionMode?: PermissionMode model?: string | null + collaborationMode?: CodexCollaborationMode } ): Promise { const result = await this.rpcGateway.requestSessionConfig(sessionId, config) if (!result || typeof result !== 'object') { throw new Error('Invalid response from session config RPC') } - const obj = result as { applied?: { permissionMode?: Session['permissionMode']; model?: Session['model'] } } + const obj = result as { + applied?: { + permissionMode?: Session['permissionMode'] + model?: Session['model'] + collaborationMode?: Session['collaborationMode'] + } + } const applied = obj.applied if (!applied || typeof applied !== 'object') { throw new Error('Missing applied session config') diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts new file mode 100644 index 00000000..c4ff06a1 --- /dev/null +++ b/hub/src/web/routes/sessions.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import type { Session, SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createSessionsRoutes } from './sessions' + +function createSession(overrides?: Partial): Session { + const baseMetadata = { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex' as const, + codexRemoteBackend: 'app-server' as const + } + const base: Session = { + id: 'session-1', + namespace: 'default', + seq: 1, + createdAt: 1, + updatedAt: 1, + active: true, + activeAt: 1, + metadata: baseMetadata, + metadataVersion: 1, + agentState: { + controlledByUser: false, + requests: {}, + completedRequests: {} + }, + agentStateVersion: 1, + thinking: false, + thinkingAt: 1, + model: 'gpt-5.4', + permissionMode: 'default', + collaborationMode: 'default' + } + + return { + ...base, + ...overrides, + metadata: overrides?.metadata === undefined + ? base.metadata + : overrides.metadata === null + ? null + : { + ...baseMetadata, + ...overrides.metadata + }, + agentState: overrides?.agentState === undefined ? base.agentState : overrides.agentState + } +} + +function createApp(session: Session) { + const applySessionConfigCalls: Array<[string, { collaborationMode: string }]> = [] + const applySessionConfig = async (sessionId: string, config: { collaborationMode: string }) => { + applySessionConfigCalls.push([sessionId, config]) + } + const engine = { + resolveSessionAccess: () => ({ ok: true, sessionId: session.id, session }), + applySessionConfig + } as Partial + + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createSessionsRoutes(() => engine as SyncEngine)) + + return { app, applySessionConfigCalls } +} + +describe('sessions routes', () => { + it('rejects collaboration mode changes for MCP-backed Codex sessions', async () => { + const session = createSession({ + metadata: { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + codexRemoteBackend: 'mcp-server' + } + }) + const { app, applySessionConfigCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/collaboration-mode', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'plan' }) + }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: 'Collaboration mode is only supported for Codex app-server remote sessions' + }) + expect(applySessionConfigCalls).toEqual([]) + }) + + it('applies collaboration mode changes for app-server Codex sessions', async () => { + const { app, applySessionConfigCalls } = createApp(createSession()) + + const response = await app.request('/api/sessions/session-1/collaboration-mode', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'plan' }) + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true }) + expect(applySessionConfigCalls).toEqual([ + ['session-1', { collaborationMode: 'plan' }] + ]) + }) +}) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index a4720a00..d17d0b84 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -1,5 +1,5 @@ import { getPermissionModesForFlavor, isPermissionModeAllowedForFlavor, toSessionSummary } from '@hapi/protocol' -import { PermissionModeSchema } from '@hapi/protocol/schemas' +import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas' import { Hono } from 'hono' import { z } from 'zod' import type { SyncEngine, Session } from '../../sync/syncEngine' @@ -10,6 +10,10 @@ const permissionModeSchema = z.object({ mode: PermissionModeSchema }) +const collaborationModeSchema = z.object({ + mode: CodexCollaborationModeSchema +}) + const modelSchema = z.object({ model: z.string().trim().min(1).nullable() }) @@ -256,6 +260,43 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.post('/sessions/:id/collaboration-mode', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) { + return sessionResult + } + + const flavor = sessionResult.session.metadata?.flavor ?? 'claude' + if (flavor !== 'codex') { + return c.json({ error: 'Collaboration mode is only supported for Codex sessions' }, 400) + } + if (sessionResult.session.agentState?.controlledByUser === true) { + return c.json({ error: 'Collaboration mode can only be changed for remote Codex sessions' }, 409) + } + if (sessionResult.session.metadata?.codexRemoteBackend !== 'app-server') { + return c.json({ error: 'Collaboration mode is only supported for Codex app-server remote sessions' }, 409) + } + + const body = await c.req.json().catch(() => null) + const parsed = collaborationModeSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + + try { + await engine.applySessionConfig(sessionResult.sessionId, { collaborationMode: parsed.data.mode }) + return c.json({ ok: true }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to apply collaboration mode' + return c.json({ error: message }, 409) + } + }) + app.post('/sessions/:id/model', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/shared/src/modes.ts b/shared/src/modes.ts index dcc3e3bb..bc99a35d 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -4,6 +4,9 @@ export type ClaudePermissionMode = typeof CLAUDE_PERMISSION_MODES[number] export const CODEX_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const export type CodexPermissionMode = typeof CODEX_PERMISSION_MODES[number] +export const CODEX_COLLABORATION_MODES = ['default', 'plan'] as const +export type CodexCollaborationMode = typeof CODEX_COLLABORATION_MODES[number] + export const GEMINI_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const export type GeminiPermissionMode = typeof GEMINI_PERMISSION_MODES[number] @@ -60,6 +63,11 @@ export type PermissionModeOption = { tone: PermissionModeTone } +export type CodexCollaborationModeOption = { + mode: CodexCollaborationMode + label: string +} + export const CLAUDE_MODEL_LABELS: Record = { sonnet: 'Sonnet', 'sonnet[1m]': 'Sonnet 1M', @@ -67,6 +75,11 @@ export const CLAUDE_MODEL_LABELS: Record = { 'opus[1m]': 'Opus 1M' } +export const CODEX_COLLABORATION_MODE_LABELS: Record = { + default: 'Default', + plan: 'Plan' +} + export function isClaudeModelPreset(model: string | null | undefined): model is ClaudeModelPreset { return typeof model === 'string' && CLAUDE_MODEL_PRESETS.includes(model as ClaudeModelPreset) } @@ -88,6 +101,10 @@ export function getPermissionModeTone(mode: PermissionMode): PermissionModeTone return PERMISSION_MODE_TONES[mode] } +export function getCodexCollaborationModeLabel(mode: CodexCollaborationMode): string { + return CODEX_COLLABORATION_MODE_LABELS[mode] +} + export function getPermissionModesForFlavor(flavor?: string | null): readonly PermissionMode[] { if (flavor === 'codex') { return CODEX_PERMISSION_MODES @@ -115,3 +132,10 @@ export function getPermissionModeOptionsForFlavor(flavor?: string | null): Permi export function isPermissionModeAllowedForFlavor(mode: PermissionMode, flavor?: string | null): boolean { return getPermissionModesForFlavor(flavor).includes(mode) } + +export function getCodexCollaborationModeOptions(): CodexCollaborationModeOption[] { + return CODEX_COLLABORATION_MODES.map((mode) => ({ + mode, + label: getCodexCollaborationModeLabel(mode) + })) +} diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index df2b4774..ae96ce7a 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -1,7 +1,8 @@ import { z } from 'zod' -import { PERMISSION_MODES } from './modes' +import { CODEX_COLLABORATION_MODES, PERMISSION_MODES } from './modes' export const PermissionModeSchema = z.enum(PERMISSION_MODES) +export const CodexCollaborationModeSchema = z.enum(CODEX_COLLABORATION_MODES) const MetadataSummarySchema = z.object({ text: z.string(), @@ -45,6 +46,7 @@ export const MetadataSchema = z.object({ archivedBy: z.string().optional(), archiveReason: z.string().optional(), flavor: z.string().nullish(), + codexRemoteBackend: z.enum(['app-server', 'mcp-server']).optional(), worktree: WorktreeMetadataSchema.optional() }) @@ -174,7 +176,8 @@ export const SessionSchema = z.object({ todos: TodosSchema.optional(), teamState: TeamStateSchema.optional(), model: z.string().nullable(), - permissionMode: PermissionModeSchema.optional() + permissionMode: PermissionModeSchema.optional(), + collaborationMode: CodexCollaborationModeSchema.optional() }) export type Session = z.infer diff --git a/shared/src/socket.ts b/shared/src/socket.ts index 64181fdd..462bce9f 100644 --- a/shared/src/socket.ts +++ b/shared/src/socket.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import type { PermissionMode } from './modes' +import type { CodexCollaborationMode, PermissionMode } from './modes' export type SocketErrorReason = 'namespace-missing' | 'access-denied' | 'not-found' @@ -140,6 +140,7 @@ export interface ClientToServerEvents { mode?: 'local' | 'remote' permissionMode?: PermissionMode model?: string | null + collaborationMode?: CodexCollaborationMode }) => void 'session-end': (data: { sid: string; time: number }) => void 'update-metadata': (data: { sid: string; expectedVersion: number; metadata: unknown }, cb: (answer: { diff --git a/shared/src/types.ts b/shared/src/types.ts index 4522f30b..d6d5fd3e 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -20,6 +20,8 @@ export type { SessionSummary, SessionSummaryMetadata } from './sessionSummary' export type { AgentFlavor, ClaudePermissionMode, + CodexCollaborationMode, + CodexCollaborationModeOption, CodexPermissionMode, CursorPermissionMode, GeminiPermissionMode, diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d7574a27..065c6597 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,6 +1,7 @@ import type { AttachmentMetadata, AuthResponse, + CodexCollaborationMode, DeleteUploadResponse, ListDirectoryResponse, FileReadResponse, @@ -312,6 +313,13 @@ export class ApiClient { }) } + async setCollaborationMode(sessionId: string, mode: CodexCollaborationMode): Promise { + await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/collaboration-mode`, { + method: 'POST', + body: JSON.stringify({ mode }) + }) + } + async setModel(sessionId: string, model: string | null): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/model`, { method: 'POST', diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 71887cb6..235ce299 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -1,4 +1,4 @@ -import { getPermissionModeOptionsForFlavor } from '@hapi/protocol' +import { getCodexCollaborationModeOptions, getPermissionModeOptionsForFlavor } from '@hapi/protocol' import { ComposerPrimitive, useAssistantApi, useAssistantState } from '@assistant-ui/react' import { type ChangeEvent as ReactChangeEvent, @@ -12,7 +12,7 @@ import { useRef, useState } from 'react' -import type { AgentState, PermissionMode } from '@/types/api' +import type { AgentState, CodexCollaborationMode, PermissionMode } from '@/types/api' import type { Suggestion } from '@/hooks/useActiveSuggestions' import type { ConversationStatus } from '@/realtime/types' import { useActiveWord } from '@/hooks/useActiveWord' @@ -40,6 +40,7 @@ const defaultSuggestionHandler = async (): Promise => [] export function HappyComposer(props: { disabled?: boolean permissionMode?: PermissionMode + collaborationMode?: CodexCollaborationMode model?: string | null active?: boolean allowSendWhenInactive?: boolean @@ -48,6 +49,7 @@ export function HappyComposer(props: { contextSize?: number controlledByUser?: boolean agentFlavor?: string | null + onCollaborationModeChange?: (mode: CodexCollaborationMode) => void onPermissionModeChange?: (mode: PermissionMode) => void onModelChange?: (model: string | null) => void onSwitchToRemote?: () => void @@ -64,6 +66,7 @@ export function HappyComposer(props: { const { disabled = false, permissionMode: rawPermissionMode, + collaborationMode: rawCollaborationMode, model: rawModel, active = true, allowSendWhenInactive = false, @@ -72,6 +75,7 @@ export function HappyComposer(props: { contextSize, controlledByUser = false, agentFlavor, + onCollaborationModeChange, onPermissionModeChange, onModelChange, onSwitchToRemote, @@ -86,6 +90,7 @@ export function HappyComposer(props: { // Use ?? so missing values fall back to default (destructuring defaults only handle undefined) const permissionMode = rawPermissionMode ?? 'default' + const collaborationMode = rawCollaborationMode ?? 'default' const model = rawModel ?? null const api = useAssistantApi() @@ -246,6 +251,10 @@ export function HappyComposer(props: { () => getPermissionModeOptionsForFlavor(agentFlavor), [agentFlavor] ) + const collaborationModeOptions = useMemo( + () => agentFlavor === 'codex' ? getCodexCollaborationModeOptions() : [], + [agentFlavor] + ) const claudeModelOptions = useMemo( () => getClaudeComposerModelOptions(model), [model] @@ -393,6 +402,13 @@ export function HappyComposer(props: { haptic('light') }, [onPermissionModeChange, controlsDisabled, haptic]) + const handleCollaborationChange = useCallback((mode: CodexCollaborationMode) => { + if (!onCollaborationModeChange || controlsDisabled) return + onCollaborationModeChange(mode) + setShowSettings(false) + haptic('light') + }, [onCollaborationModeChange, controlsDisabled, haptic]) + const handleModelChange = useCallback((nextModel: string | null) => { if (!onModelChange || controlsDisabled) return onModelChange(nextModel) @@ -400,9 +416,10 @@ export function HappyComposer(props: { haptic('light') }, [onModelChange, controlsDisabled, haptic]) + const showCollaborationSettings = Boolean(onCollaborationModeChange && collaborationModeOptions.length > 0) const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0) const showModelSettings = Boolean(onModelChange && isClaudeFlavor(agentFlavor)) - const showSettingsButton = Boolean(showPermissionSettings || showModelSettings) + const showSettingsButton = Boolean(showCollaborationSettings || showPermissionSettings || showModelSettings) const showAbortButton = true const voiceEnabled = Boolean(onVoiceToggle) @@ -411,10 +428,51 @@ export function HappyComposer(props: { }, [api]) const overlays = useMemo(() => { - if (showSettings && (showPermissionSettings || showModelSettings)) { + if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings)) { return (
+ {showCollaborationSettings ? ( +
+
+ {t('misc.collaborationMode')} +
+ {collaborationModeOptions.map((option) => ( + + ))} +
+ ) : null} + + {showCollaborationSettings && (showPermissionSettings || showModelSettings) ? ( +
+ ) : null} + {showPermissionSettings ? (
@@ -452,7 +510,7 @@ export function HappyComposer(props: {
) : null} - {showPermissionSettings && showModelSettings ? ( + {(showCollaborationSettings || showPermissionSettings) && showModelSettings ? (
) : null} @@ -514,15 +572,19 @@ export function HappyComposer(props: { return null }, [ showSettings, + showCollaborationSettings, showPermissionSettings, showModelSettings, claudeModelOptions, suggestions, selectedIndex, controlsDisabled, + collaborationMode, permissionMode, model, + collaborationModeOptions, permissionModeOptions, + handleCollaborationChange, handlePermissionChange, handleModelChange, handleSuggestionSelect, @@ -542,6 +604,7 @@ export function HappyComposer(props: { contextSize={contextSize} model={model} permissionMode={permissionMode} + collaborationMode={collaborationMode} agentFlavor={agentFlavor} voiceStatus={voiceStatus} /> diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index 7128e963..e026c99f 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -1,7 +1,12 @@ -import { getPermissionModeLabel, getPermissionModeTone, isPermissionModeAllowedForFlavor } from '@hapi/protocol' +import { + getCodexCollaborationModeLabel, + getPermissionModeLabel, + getPermissionModeTone, + isPermissionModeAllowedForFlavor +} from '@hapi/protocol' import type { PermissionModeTone } from '@hapi/protocol' import { useMemo } from 'react' -import type { AgentState, PermissionMode } from '@/types/api' +import type { AgentState, CodexCollaborationMode, PermissionMode } from '@/types/api' import type { ConversationStatus } from '@/realtime/types' import { getContextBudgetTokens } from '@/chat/modelConfig' import { useTranslation } from '@/lib/use-translation' @@ -108,6 +113,7 @@ export function StatusBar(props: { contextSize?: number model?: string | null permissionMode?: PermissionMode + collaborationMode?: CodexCollaborationMode agentFlavor?: string | null voiceStatus?: ConversationStatus }) { @@ -137,6 +143,12 @@ export function StatusBar(props: { const permissionModeLabel = displayPermissionMode ? getPermissionModeLabel(displayPermissionMode) : null const permissionModeTone = displayPermissionMode ? getPermissionModeTone(displayPermissionMode) : null const permissionModeColor = permissionModeTone ? PERMISSION_TONE_CLASSES[permissionModeTone] : 'text-[var(--app-hint)]' + const displayCollaborationMode = props.agentFlavor === 'codex' && props.collaborationMode === 'plan' + ? props.collaborationMode + : null + const collaborationModeLabel = displayCollaborationMode + ? getCodexCollaborationModeLabel(displayCollaborationMode) + : null return (
@@ -156,11 +168,18 @@ export function StatusBar(props: { ) : null}
- {displayPermissionMode ? ( - - {permissionModeLabel} - - ) : null} +
+ {collaborationModeLabel ? ( + + {collaborationModeLabel} + + ) : null} + {displayPermissionMode ? ( + + {permissionModeLabel} + + ) : null} +
) } diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index c28d9c43..c9820890 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from '@tanstack/react-router' import { AssistantRuntimeProvider } from '@assistant-ui/react' import type { ApiClient } from '@/api/client' -import type { AttachmentMetadata, DecryptedMessage, PermissionMode, Session } from '@/types/api' +import type { AttachmentMetadata, CodexCollaborationMode, DecryptedMessage, PermissionMode, Session } from '@/types/api' import type { ChatBlock, NormalizedMessage } from '@/chat/types' import type { Suggestion } from '@/hooks/useActiveSuggestions' import { normalizeDecryptedMessage } from '@/chat/normalize' @@ -46,10 +46,14 @@ export function SessionChat(props: { const blocksByIdRef = useRef>(new Map()) const [forceScrollToken, setForceScrollToken] = useState(0) const agentFlavor = props.session.metadata?.flavor ?? null - const { abortSession, switchSession, setPermissionMode, setModel } = useSessionActions( + const codexCollaborationModeSupported = agentFlavor === 'codex' + && props.session.metadata?.codexRemoteBackend === 'app-server' + const controlledByUser = props.session.agentState?.controlledByUser === true + const { abortSession, switchSession, setPermissionMode, setCollaborationMode, setModel } = useSessionActions( props.api, props.session.id, - agentFlavor + agentFlavor, + codexCollaborationModeSupported ) // Voice assistant integration @@ -205,6 +209,17 @@ export function SessionChat(props: { } }, [setPermissionMode, props.onRefresh, haptic]) + const handleCollaborationModeChange = useCallback(async (mode: CodexCollaborationMode) => { + try { + await setCollaborationMode(mode) + haptic.notification('success') + props.onRefresh() + } catch (e) { + haptic.notification('error') + console.error('Failed to set collaboration mode:', e) + } + }, [setCollaborationMode, props.onRefresh, haptic]) + // Model mode change handler const handleModelChange = useCallback(async (model: string | null) => { try { @@ -314,6 +329,7 @@ export function SessionChat(props: { Promise archiveSession: () => Promise switchSession: () => Promise setPermissionMode: (mode: PermissionMode) => Promise + setCollaborationMode: (mode: CodexCollaborationMode) => Promise setModel: (model: string | null) => Promise renameSession: (name: string) => Promise deleteSession: () => Promise @@ -71,6 +73,22 @@ export function useSessionActions( onSuccess: () => void invalidateSession(), }) + const collaborationMutation = useMutation({ + mutationFn: async (mode: CodexCollaborationMode) => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + if (agentFlavor !== 'codex') { + throw new Error('Collaboration mode is only supported for Codex sessions') + } + if (!codexCollaborationModeSupported) { + throw new Error('Collaboration mode is only supported for Codex app-server remote sessions') + } + await api.setCollaborationMode(sessionId, mode) + }, + onSuccess: () => void invalidateSession(), + }) + const modelMutation = useMutation({ mutationFn: async (model: string | null) => { if (!api || !sessionId) { @@ -111,6 +129,7 @@ export function useSessionActions( archiveSession: archiveMutation.mutateAsync, switchSession: switchMutation.mutateAsync, setPermissionMode: permissionMutation.mutateAsync, + setCollaborationMode: collaborationMutation.mutateAsync, setModel: modelMutation.mutateAsync, renameSession: renameMutation.mutateAsync, deleteSession: deleteMutation.mutateAsync, @@ -118,6 +137,7 @@ export function useSessionActions( || archiveMutation.isPending || switchMutation.isPending || permissionMutation.isPending + || collaborationMutation.isPending || modelMutation.isPending || renameMutation.isPending || deleteMutation.isPending, diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index 90da7baa..a1373b1a 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -30,7 +30,7 @@ const RECONNECT_MAX_DELAY_MS = 30_000 const RECONNECT_JITTER_MS = 500 const INVALIDATION_BATCH_MS = 16 -type SessionPatch = Partial> +type SessionPatch = Partial> function sortSessionSummaries(left: SessionSummary, right: SessionSummary): number { if (left.active !== right.active) { @@ -89,6 +89,10 @@ function getSessionPatch(value: unknown): SessionPatch | null { patch.permissionMode = value.permissionMode as Session['permissionMode'] hasKnownPatch = true } + if (typeof value.collaborationMode === 'string') { + patch.collaborationMode = value.collaborationMode as Session['collaborationMode'] + hasKnownPatch = true + } return hasKnownPatch ? patch : null } @@ -97,7 +101,7 @@ function hasUnknownSessionPatchKeys(value: unknown): boolean { if (!hasRecordShape(value)) { return false } - const knownKeys = new Set(['active', 'thinking', 'activeAt', 'updatedAt', 'model', 'permissionMode']) + const knownKeys = new Set(['active', 'thinking', 'activeAt', 'updatedAt', 'model', 'permissionMode', 'collaborationMode']) return Object.keys(value).some((key) => !knownKeys.has(key)) } diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 71cd5edf..19cdb668 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -264,6 +264,7 @@ export default { // Misc 'misc.noMachines': 'No machines available', 'misc.machine': 'Machine', + 'misc.collaborationMode': 'Collaboration Mode', 'misc.permissionMode': 'Permission Mode', 'misc.model': 'Model', 'misc.loading': 'Loading…', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 2419a4d4..8e596b80 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -266,6 +266,7 @@ export default { // Misc 'misc.noMachines': '无可用机器', 'misc.machine': '机器', + 'misc.collaborationMode': '协作模式', 'misc.permissionMode': '权限模式', 'misc.model': '模型', 'misc.loading': '加载中…', diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 332a2b6c..0a2b01b1 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -9,6 +9,7 @@ import type { export type { AgentState, AttachmentMetadata, + CodexCollaborationMode, PermissionMode, Session, SessionSummary,