diff --git a/cli/src/agent/sessionBase.ts b/cli/src/agent/sessionBase.ts index 2a5c422d..1dc382a1 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 } from '@/api/types'; +import type { Metadata, SessionModelMode, SessionPermissionMode } from '@/api/types'; import { logger } from '@/ui/logger'; export type AgentSessionBaseOptions = { @@ -15,6 +15,8 @@ export type AgentSessionBaseOptions = { sessionLabel: string; sessionIdLabel: string; applySessionIdToMetadata: (metadata: Metadata, sessionId: string) => Metadata; + permissionMode?: SessionPermissionMode; + modelMode?: SessionModelMode; }; export class AgentSessionBase { @@ -34,6 +36,8 @@ export class AgentSessionBase { private readonly sessionLabel: string; private readonly sessionIdLabel: string; private keepAliveInterval: NodeJS.Timeout | null = null; + protected permissionMode?: SessionPermissionMode; + protected modelMode?: SessionModelMode; constructor(opts: AgentSessionBaseOptions) { this.path = opts.path; @@ -47,21 +51,26 @@ export class AgentSessionBase { this.sessionLabel = opts.sessionLabel; this.sessionIdLabel = opts.sessionIdLabel; this.mode = opts.mode ?? 'local'; + this.permissionMode = opts.permissionMode; + this.modelMode = opts.modelMode; - this.client.keepAlive(this.thinking, this.mode); + this.client.keepAlive(this.thinking, this.mode, this.getKeepAliveRuntime()); this.keepAliveInterval = setInterval(() => { - this.client.keepAlive(this.thinking, this.mode); + this.client.keepAlive(this.thinking, this.mode, this.getKeepAliveRuntime()); }, 2000); } onThinkingChange = (thinking: boolean) => { this.thinking = thinking; - this.client.keepAlive(thinking, this.mode); + this.client.keepAlive(thinking, this.mode, this.getKeepAliveRuntime()); }; onModeChange = (mode: 'local' | 'remote') => { this.mode = mode; - this.client.keepAlive(this.thinking, mode); + this.client.keepAlive(this.thinking, mode, this.getKeepAliveRuntime()); + const permissionLabel = this.permissionMode ?? 'unset'; + const modelLabel = this.modelMode ?? 'unset'; + logger.debug(`[${this.sessionLabel}] Mode switched to ${mode} (permissionMode=${permissionLabel}, modelMode=${modelLabel})`); this._onModeChange(mode); }; @@ -92,4 +101,22 @@ export class AgentSessionBase { this.keepAliveInterval = null; } }; + + protected getKeepAliveRuntime(): { permissionMode?: SessionPermissionMode; modelMode?: SessionModelMode } | undefined { + if (this.permissionMode === undefined && this.modelMode === undefined) { + return undefined; + } + return { + permissionMode: this.permissionMode, + modelMode: this.modelMode + }; + } + + getPermissionMode(): SessionPermissionMode | undefined { + return this.permissionMode; + } + + getModelMode(): SessionModelMode | undefined { + return this.modelMode; + } } diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 50422373..90d95183 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -7,7 +7,19 @@ import { backoff } from '@/utils/time' import { AsyncLock } from '@/utils/lock' import type { RawJSONLines } from '@/claude/types' import { configuration } from '@/configuration' -import type { AgentState, ClientToServerEvents, MessageContent, MessageMeta, Metadata, ServerToClientEvents, Session, Update, UserMessage } from './types' +import type { + AgentState, + ClientToServerEvents, + MessageContent, + MessageMeta, + Metadata, + ServerToClientEvents, + Session, + SessionModelMode, + SessionPermissionMode, + Update, + UserMessage +} from './types' import { AgentStateSchema, MetadataSchema, UserMessageSchema } from './types' import { RpcHandlerManager } from './rpc/RpcHandlerManager' import { registerCommonHandlers } from '../modules/common/registerCommonHandlers' @@ -300,12 +312,17 @@ export class ApiSessionClient extends EventEmitter { }) } - keepAlive(thinking: boolean, mode: 'local' | 'remote'): void { + keepAlive( + thinking: boolean, + mode: 'local' | 'remote', + runtime?: { permissionMode?: SessionPermissionMode; modelMode?: SessionModelMode } + ): void { this.socket.volatile.emit('session-alive', { sid: this.sessionId, time: Date.now(), thinking, - mode + mode, + ...(runtime ?? {}) }) } diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index fced3c0e..9dbdf84e 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -1,6 +1,5 @@ import { z } from 'zod' import { UsageSchema } from '@/claude/types' -import type { PermissionMode } from '@/claude/loop' import type { TerminalClosePayload, TerminalExitPayload, @@ -14,6 +13,11 @@ import type { export type Usage = z.infer +export type ClaudePermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' +export type CodexPermissionMode = 'default' | 'read-only' | 'safe-yolo' | 'yolo' +export type SessionPermissionMode = ClaudePermissionMode | CodexPermissionMode +export type SessionModelMode = 'default' | 'sonnet' | 'opus' + export type Metadata = { path: string host: string @@ -145,8 +149,8 @@ export type Session = { agentStateVersion: number thinking?: boolean thinkingAt?: number - permissionMode?: PermissionMode | null - modelMode?: 'default' | 'sonnet' | 'opus' | null + permissionMode?: SessionPermissionMode + modelMode?: SessionModelMode } export const MachineMetadataSchema = z.object({ @@ -273,8 +277,6 @@ export type CreateMachineResponse = z.infer export const MessageMetaSchema = z.object({ sentFrom: z.string().optional(), - permissionMode: z.string().optional(), - model: z.string().nullable().optional(), fallbackModel: z.string().nullable().optional(), customSystemPrompt: z.string().nullable().optional(), appendSystemPrompt: z.string().nullable().optional(), @@ -323,7 +325,14 @@ export interface ServerToClientEvents { export interface ClientToServerEvents { message: (data: { sid: string; message: unknown; localId?: string }) => void - 'session-alive': (data: { sid: string; time: number; thinking: boolean; mode?: 'local' | 'remote' }) => void + 'session-alive': (data: { + sid: string + time: number + thinking: boolean + mode?: 'local' | 'remote' + permissionMode?: SessionPermissionMode + modelMode?: SessionModelMode + }) => void 'session-end': (data: { sid: string; time: number }) => void 'update-metadata': (data: { sid: string; expectedVersion: number; metadata: unknown }, cb: (answer: { result: 'error' diff --git a/cli/src/claude/loop.ts b/cli/src/claude/loop.ts index 6871383e..06648cb0 100644 --- a/cli/src/claude/loop.ts +++ b/cli/src/claude/loop.ts @@ -6,6 +6,7 @@ import { Session } from "./session" import { claudeLocalLauncher } from "./claudeLocalLauncher" import { claudeRemoteLauncher } from "./claudeRemoteLauncher" import { ApiClient } from "@/lib" +import type { SessionModelMode } from "@/api/types" export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'; @@ -43,6 +44,9 @@ export async function loop(opts: LoopOptions) { const logPath = logger.logFilePath; const startedBy = opts.startedBy ?? 'terminal'; const startingMode = opts.startingMode ?? 'local'; + const modelMode: SessionModelMode = opts.model === 'sonnet' || opts.model === 'opus' + ? opts.model + : 'default'; let session = new Session({ api: opts.api, client: opts.session, @@ -58,7 +62,9 @@ export async function loop(opts: LoopOptions) { mode: startingMode, startedBy, startingMode, - hookSettingsPath: opts.hookSettingsPath + hookSettingsPath: opts.hookSettingsPath, + permissionMode: opts.permissionMode ?? 'default', + modelMode }); // Notify that session is ready diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index 86014d3e..83d6d064 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -5,7 +5,7 @@ import { ApiClient } from '@/api/api'; import { logger } from '@/ui/logger'; import { restoreTerminalState } from '@/ui/terminalState'; import { loop } from '@/claude/loop'; -import { AgentState, Metadata } from '@/api/types'; +import { AgentState, Metadata, SessionModelMode } from '@/api/types'; import packageJson from '../../package.json'; import { readSettings } from '@/persistence'; import { EnhancedMode, PermissionMode } from './loop'; @@ -190,40 +190,37 @@ export async function runClaude(options: StartOptions = {}): Promise { })); // Forward messages to the queue - let currentPermissionMode = options.permissionMode; + let currentPermissionMode: PermissionMode = options.permissionMode ?? 'default'; let currentModel = options.model; // Track current model state + let currentModelMode: SessionModelMode = currentModel === 'sonnet' || currentModel === 'opus' ? currentModel : 'default'; let currentFallbackModel: string | undefined = undefined; // Track current fallback model let currentCustomSystemPrompt: string | undefined = undefined; // Track current custom system prompt let currentAppendSystemPrompt: string | undefined = undefined; // Track current append system prompt let currentAllowedTools: string[] | undefined = undefined; // Track current allowed tools let currentDisallowedTools: string[] | undefined = undefined; // Track current disallowed tools + + const syncSessionModes = () => { + const sessionInstance = currentSessionRef.current; + if (!sessionInstance) { + return; + } + sessionInstance.setPermissionMode(currentPermissionMode); + sessionInstance.setModelMode(currentModelMode); + logger.debug(`[loop] Synced session modes for keepalive: permissionMode=${currentPermissionMode}, modelMode=${currentModelMode}`); + }; session.onUserMessage((message) => { - - // Resolve permission mode from meta - let messagePermissionMode = currentPermissionMode; - if (message.meta?.permissionMode) { - const validModes: PermissionMode[] = ['default', 'acceptEdits', 'bypassPermissions', 'plan']; - if (validModes.includes(message.meta.permissionMode as PermissionMode)) { - messagePermissionMode = message.meta.permissionMode as PermissionMode; - currentPermissionMode = messagePermissionMode; - logger.debug(`[loop] Permission mode updated from user message to: ${currentPermissionMode}`); - - } else { - logger.debug(`[loop] Invalid permission mode received: ${message.meta.permissionMode}`); - } - } else { - logger.debug(`[loop] User message received with no permission mode override, using current: ${currentPermissionMode}`); - } - - // Resolve model - use message.meta.model if provided, otherwise use current model - let messageModel = currentModel; - if (message.meta?.hasOwnProperty('model')) { - messageModel = message.meta.model || undefined; // null becomes undefined - currentModel = messageModel; - logger.debug(`[loop] Model updated from user message: ${messageModel || 'reset to default'}`); - } else { - logger.debug(`[loop] User message received with no model override, using current: ${currentModel || 'default'}`); + const sessionPermissionMode = currentSessionRef.current?.getPermissionMode(); + if ( + sessionPermissionMode === 'default' + || sessionPermissionMode === 'acceptEdits' + || sessionPermissionMode === 'bypassPermissions' + || sessionPermissionMode === 'plan' + ) { + currentPermissionMode = sessionPermissionMode; } + const messagePermissionMode = currentPermissionMode; + const messageModel = currentModel; + logger.debug(`[loop] User message received with permission mode: ${currentPermissionMode}, model: ${currentModelMode}`); // Resolve custom system prompt - use message.meta.customSystemPrompt if provided, otherwise use current let messageCustomSystemPrompt = currentCustomSystemPrompt; @@ -281,7 +278,7 @@ export async function runClaude(options: StartOptions = {}): Promise { if (specialCommand.type === 'compact') { logger.debug('[start] Detected /compact command'); const enhancedMode: EnhancedMode = { - permissionMode: messagePermissionMode || 'default', + permissionMode: messagePermissionMode ?? 'default', model: messageModel, fallbackModel: messageFallbackModel, customSystemPrompt: messageCustomSystemPrompt, @@ -297,7 +294,7 @@ export async function runClaude(options: StartOptions = {}): Promise { if (specialCommand.type === 'clear') { logger.debug('[start] Detected /clear command'); const enhancedMode: EnhancedMode = { - permissionMode: messagePermissionMode || 'default', + permissionMode: messagePermissionMode ?? 'default', model: messageModel, fallbackModel: messageFallbackModel, customSystemPrompt: messageCustomSystemPrompt, @@ -312,7 +309,7 @@ export async function runClaude(options: StartOptions = {}): Promise { // Push with resolved permission mode, model, system prompts, and tools const enhancedMode: EnhancedMode = { - permissionMode: messagePermissionMode || 'default', + permissionMode: messagePermissionMode ?? 'default', model: messageModel, fallbackModel: messageFallbackModel, customSystemPrompt: messageCustomSystemPrompt, @@ -383,6 +380,33 @@ export async function runClaude(options: StartOptions = {}): Promise { registerKillSessionHandler(session.rpcHandlerManager, cleanup); + session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => { + if (!payload || typeof payload !== 'object') { + throw new Error('Invalid session config payload'); + } + const config = payload as { permissionMode?: PermissionMode; modelMode?: SessionModelMode }; + + if (config.permissionMode !== undefined) { + const validModes: PermissionMode[] = ['default', 'acceptEdits', 'bypassPermissions', 'plan']; + if (!validModes.includes(config.permissionMode)) { + throw new Error('Invalid permission mode'); + } + currentPermissionMode = config.permissionMode; + } + + if (config.modelMode !== undefined) { + const validModels: SessionModelMode[] = ['default', 'sonnet', 'opus']; + if (!validModels.includes(config.modelMode)) { + throw new Error('Invalid model mode'); + } + currentModelMode = config.modelMode; + currentModel = config.modelMode === 'default' ? undefined : config.modelMode; + } + + syncSessionModes(); + return { applied: { permissionMode: currentPermissionMode, modelMode: currentModelMode } }; + }); + // Create claude loop await loop({ path: workingDirectory, @@ -401,6 +425,7 @@ export async function runClaude(options: StartOptions = {}): Promise { }, onSessionReady: (sessionInstance) => { currentSessionRef.current = sessionInstance; + syncSessionModes(); }, mcpServers: { 'hapi': { diff --git a/cli/src/claude/session.ts b/cli/src/claude/session.ts index ced5ee3a..ae5e888a 100644 --- a/cli/src/claude/session.ts +++ b/cli/src/claude/session.ts @@ -2,7 +2,9 @@ import { ApiClient, ApiSessionClient } from '@/lib'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { logger } from '@/ui/logger'; import { AgentSessionBase } from '@/agent/sessionBase'; +import type { SessionModelMode } from '@/api/types'; import type { EnhancedMode } from './loop'; +import type { PermissionMode } from './loop'; import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; type LocalLaunchFailure = { @@ -36,6 +38,8 @@ export class Session extends AgentSessionBase { startedBy: 'daemon' | 'terminal'; startingMode: 'local' | 'remote'; hookSettingsPath: string; + permissionMode?: PermissionMode; + modelMode?: SessionModelMode; }) { super({ api: opts.api, @@ -51,7 +55,9 @@ export class Session extends AgentSessionBase { applySessionIdToMetadata: (metadata, sessionId) => ({ ...metadata, claudeSessionId: sessionId - }) + }), + permissionMode: opts.permissionMode, + modelMode: opts.modelMode }); this.claudeEnvVars = opts.claudeEnvVars; @@ -61,8 +67,18 @@ export class Session extends AgentSessionBase { this.hookSettingsPath = opts.hookSettingsPath; this.startedBy = opts.startedBy; this.startingMode = opts.startingMode; + this.permissionMode = opts.permissionMode; + this.modelMode = opts.modelMode; } + setPermissionMode = (mode: PermissionMode): void => { + this.permissionMode = mode; + }; + + setModelMode = (mode: SessionModelMode): void => { + this.modelMode = mode; + }; + recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { this.localLaunchFailure = { message, exitReason }; }; diff --git a/cli/src/claude/utils/permissionHandler.ts b/cli/src/claude/utils/permissionHandler.ts index 4db505d0..c51ae386 100644 --- a/cli/src/claude/utils/permissionHandler.ts +++ b/cli/src/claude/utils/permissionHandler.ts @@ -121,6 +121,7 @@ export class PermissionHandler { handleModeChange(mode: PermissionMode) { this.permissionMode = mode; + this.session.setPermissionMode(mode); } /** @@ -148,6 +149,7 @@ export class PermissionHandler { // Update permission mode if (response.mode) { this.permissionMode = response.mode; + this.session.setPermissionMode(response.mode); } // Handle diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index fe754cb7..cadd7c09 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -24,6 +24,7 @@ interface LoopOptions { api: ApiClient; codexArgs?: string[]; codexCliOverrides?: CodexCliOverrides; + permissionMode?: PermissionMode; onSessionReady?: (session: CodexSession) => void; } @@ -43,7 +44,8 @@ export async function loop(opts: LoopOptions): Promise { startedBy, startingMode, codexArgs: opts.codexArgs, - codexCliOverrides: opts.codexCliOverrides + codexCliOverrides: opts.codexCliOverrides, + permissionMode: opts.permissionMode ?? 'default' }); if (opts.onSessionReady) { diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index 43118998..e4fac665 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -25,6 +25,7 @@ export { emitReadyIfIdle } from './utils/emitReadyIfIdle'; export async function runCodex(opts: { startedBy?: 'daemon' | 'terminal'; codexArgs?: string[]; + permissionMode?: PermissionMode; }): Promise { const workingDirectory = process.cwd(); const sessionTag = randomUUID(); @@ -99,43 +100,29 @@ export async function runCodex(opts: { })); const codexCliOverrides = parseCodexCliOverrides(opts.codexArgs); + const sessionWrapperRef: { current: CodexSession | null } = { current: null }; - let currentPermissionMode: PermissionMode | undefined = undefined; - let currentModel: string | undefined = undefined; + let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; + + const syncSessionMode = () => { + const sessionInstance = sessionWrapperRef.current; + if (!sessionInstance) { + return; + } + sessionInstance.setPermissionMode(currentPermissionMode); + logger.debug(`[Codex] Synced session permission mode for keepalive: ${currentPermissionMode}`); + }; session.onUserMessage((message) => { - let messagePermissionMode = currentPermissionMode; - if (message.meta?.permissionMode) { - const validModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; - if (validModes.includes(message.meta.permissionMode as PermissionMode)) { - messagePermissionMode = message.meta.permissionMode as PermissionMode; - currentPermissionMode = messagePermissionMode; - logger.debug(`[Codex] Permission mode updated from user message to: ${currentPermissionMode}`); - } else { - logger.debug(`[Codex] Invalid permission mode received: ${message.meta.permissionMode}`); - } - } else { - logger.debug(`[Codex] User message received with no permission mode override, using current: ${currentPermissionMode ?? 'default (effective)'}`); - } - - let messageModel = currentModel; - if (message.meta?.hasOwnProperty('model')) { - messageModel = message.meta.model || undefined; - currentModel = messageModel; - logger.debug(`[Codex] Model updated from user message: ${messageModel || 'reset to default'}`); - } else { - logger.debug(`[Codex] User message received with no model override, using current: ${currentModel || 'default'}`); - } + const messagePermissionMode = currentPermissionMode; + logger.debug(`[Codex] User message received with permission mode: ${currentPermissionMode}`); const enhancedMode: EnhancedMode = { - permissionMode: messagePermissionMode || 'default', - model: messageModel + permissionMode: messagePermissionMode ?? 'default' }; messageQueue.push(message.content.text, enhancedMode); }); - const sessionWrapperRef: { current: CodexSession | null } = { current: null }; - let cleanupStarted = false; let exitCode = 0; let archiveReason = 'User terminated'; @@ -200,6 +187,24 @@ export async function runCodex(opts: { registerKillSessionHandler(session.rpcHandlerManager, cleanup); + session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => { + if (!payload || typeof payload !== 'object') { + throw new Error('Invalid session config payload'); + } + const config = payload as { permissionMode?: PermissionMode }; + + if (config.permissionMode !== undefined) { + const validModes: PermissionMode[] = ['default', 'read-only', 'safe-yolo', 'yolo']; + if (!validModes.includes(config.permissionMode)) { + throw new Error('Invalid permission mode'); + } + currentPermissionMode = config.permissionMode; + } + + syncSessionMode(); + return { applied: { permissionMode: currentPermissionMode } }; + }); + let loopError: unknown = null; try { await loop({ @@ -211,6 +216,7 @@ export async function runCodex(opts: { codexArgs: opts.codexArgs, codexCliOverrides, startedBy, + permissionMode: currentPermissionMode, onModeChange: (newMode) => { session.sendSessionEvent({ type: 'switch', mode: newMode }); session.updateAgentState((currentState) => ({ @@ -220,6 +226,7 @@ export async function runCodex(opts: { }, onSessionReady: (instance) => { sessionWrapperRef.current = instance; + syncSessionMode(); } }); } catch (error) { diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index 2ab0833d..d6a71815 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -1,7 +1,7 @@ import { ApiClient, ApiSessionClient } from '@/lib'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { AgentSessionBase } from '@/agent/sessionBase'; -import type { EnhancedMode } from './loop'; +import type { EnhancedMode, PermissionMode } from './loop'; import type { CodexCliOverrides } from './utils/codexCliOverrides'; import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; @@ -30,6 +30,7 @@ export class CodexSession extends AgentSessionBase { startingMode: 'local' | 'remote'; codexArgs?: string[]; codexCliOverrides?: CodexCliOverrides; + permissionMode?: PermissionMode; }) { super({ api: opts.api, @@ -45,15 +46,21 @@ export class CodexSession extends AgentSessionBase { applySessionIdToMetadata: (metadata, sessionId) => ({ ...metadata, codexSessionId: sessionId - }) + }), + permissionMode: opts.permissionMode }); this.codexArgs = opts.codexArgs; this.codexCliOverrides = opts.codexCliOverrides; this.startedBy = opts.startedBy; this.startingMode = opts.startingMode; + this.permissionMode = opts.permissionMode; } + setPermissionMode = (mode: PermissionMode): void => { + this.permissionMode = mode; + }; + recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { this.localLaunchFailure = { message, exitReason }; }; diff --git a/cli/src/index.ts b/cli/src/index.ts index 8dca17eb..4bfd5ab8 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -118,12 +118,19 @@ import { getCliArgs } from './utils/cliArgs' const { runCodex } = await import('@/codex/runCodex'); // Parse known arguments and collect unknown ones for passthrough - const options: { startedBy?: 'daemon' | 'terminal'; codexArgs?: string[] } = {}; + const options: { + startedBy?: 'daemon' | 'terminal'; + codexArgs?: string[]; + permissionMode?: 'default' | 'read-only' | 'safe-yolo' | 'yolo'; + } = {}; const unknownArgs: string[] = []; for (let i = 1; i < args.length; i++) { const arg = args[i]; if (arg === '--started-by') { options.startedBy = args[++i] as 'daemon' | 'terminal'; + } else if (arg === '--yolo' || arg === '--dangerously-bypass-approvals-and-sandbox') { + options.permissionMode = 'yolo'; + unknownArgs.push(arg); } else { unknownArgs.push(arg); } @@ -327,7 +334,11 @@ ${chalk.bold('To clean up runaway processes:')} Use ${chalk.cyan('hapi doctor cl options.startingMode = z.enum(['local', 'remote']).parse(args[++i]) } else if (arg === '--yolo') { // Shortcut for --dangerously-skip-permissions + options.permissionMode = 'bypassPermissions' unknownArgs.push('--dangerously-skip-permissions') + } else if (arg === '--dangerously-skip-permissions') { + options.permissionMode = 'bypassPermissions' + unknownArgs.push(arg) } else if (arg === '--started-by') { options.startedBy = args[++i] as 'daemon' | 'terminal' } else { diff --git a/server/src/socket/handlers/cli.ts b/server/src/socket/handlers/cli.ts index 13472204..ad2c6fee 100644 --- a/server/src/socket/handlers/cli.ts +++ b/server/src/socket/handlers/cli.ts @@ -12,6 +12,8 @@ type SessionAlivePayload = { time: number thinking?: boolean mode?: 'local' | 'remote' + permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo' + modelMode?: 'default' | 'sonnet' | 'opus' } type SessionEndPayload = { diff --git a/server/src/sync/syncEngine.ts b/server/src/sync/syncEngine.ts index d8a78d0c..12de5e0f 100644 --- a/server/src/sync/syncEngine.ts +++ b/server/src/sync/syncEngine.ts @@ -84,8 +84,8 @@ export interface Session { thinking: boolean thinkingAt: number todos?: TodoItem[] - permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | null - modelMode?: 'default' | 'sonnet' | 'opus' | null + permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo' + modelMode?: 'default' | 'sonnet' | 'opus' } export interface Machine { @@ -312,7 +312,14 @@ export class SyncEngine { this.emit(event) } - handleSessionAlive(payload: { sid: string; time: number; thinking?: boolean; mode?: 'local' | 'remote' }): void { + handleSessionAlive(payload: { + sid: string + time: number + thinking?: boolean + mode?: 'local' | 'remote' + permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo' + modelMode?: 'default' | 'sonnet' | 'opus' + }): void { const t = clampAliveTime(payload.time) if (!t) return @@ -321,21 +328,40 @@ export class SyncEngine { const wasActive = session.active const wasThinking = session.thinking + const previousPermissionMode = session.permissionMode + const previousModelMode = session.modelMode session.active = true session.activeAt = Math.max(session.activeAt, t) session.thinking = Boolean(payload.thinking) session.thinkingAt = t + if (payload.permissionMode !== undefined) { + session.permissionMode = payload.permissionMode + } + if (payload.modelMode !== undefined) { + session.modelMode = payload.modelMode + } const now = Date.now() const lastBroadcastAt = this.lastBroadcastAtBySessionId.get(session.id) ?? 0 + const modeChanged = previousPermissionMode !== session.permissionMode || previousModelMode !== session.modelMode const shouldBroadcast = (!wasActive && session.active) || (wasThinking !== session.thinking) + || modeChanged || (now - lastBroadcastAt > 10_000) if (shouldBroadcast) { this.lastBroadcastAtBySessionId.set(session.id, now) - this.emit({ type: 'session-updated', sessionId: session.id, data: { activeAt: session.activeAt, thinking: session.thinking } }) + this.emit({ + type: 'session-updated', + sessionId: session.id, + data: { + activeAt: session.activeAt, + thinking: session.thinking, + permissionMode: session.permissionMode, + modelMode: session.modelMode + } + }) } } @@ -455,8 +481,8 @@ export class SyncEngine { thinking: existing?.thinking ?? false, thinkingAt: existing?.thinkingAt ?? 0, todos, - permissionMode: existing?.permissionMode ?? null, - modelMode: existing?.modelMode ?? null + permissionMode: existing?.permissionMode, + modelMode: existing?.modelMode } this.sessions.set(sessionId, session) @@ -549,7 +575,6 @@ export class SyncEngine { } async sendMessage(sessionId: string, payload: { text: string; localId?: string | null; sentFrom?: 'telegram-bot' | 'webapp' }): Promise { - const session = this.sessions.get(sessionId) const sentFrom = payload.sentFrom ?? 'webapp' const content = { @@ -559,9 +584,7 @@ export class SyncEngine { text: payload.text }, meta: { - sentFrom, - permissionMode: session?.permissionMode || 'default', - model: session?.modelMode === 'default' ? null : session?.modelMode ?? undefined + sentFrom } } @@ -643,7 +666,7 @@ export class SyncEngine { async setPermissionMode( sessionId: string, - mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' + mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo' ): Promise { const session = this.sessions.get(sessionId) if (session) { @@ -660,6 +683,35 @@ export class SyncEngine { } } + async applySessionConfig( + sessionId: string, + config: { + permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo' + modelMode?: 'default' | 'sonnet' | 'opus' + } + ): Promise { + const result = await this.sessionRpc(sessionId, 'set-session-config', config) + if (!result || typeof result !== 'object') { + throw new Error('Invalid response from session config RPC') + } + const obj = result as { applied?: { permissionMode?: Session['permissionMode']; modelMode?: Session['modelMode'] } } + const applied = obj.applied + if (!applied || typeof applied !== 'object') { + throw new Error('Missing applied session config') + } + + const session = this.sessions.get(sessionId) ?? this.refreshSession(sessionId) + if (session) { + if (applied.permissionMode !== undefined) { + session.permissionMode = applied.permissionMode + } + if (applied.modelMode !== undefined) { + session.modelMode = applied.modelMode + } + this.emit({ type: 'session-updated', sessionId, data: session }) + } + } + async spawnSession( machineId: string, directory: string, diff --git a/server/src/web/routes/sessions.ts b/server/src/web/routes/sessions.ts index f6c96337..dbca1f27 100644 --- a/server/src/web/routes/sessions.ts +++ b/server/src/web/routes/sessions.ts @@ -27,7 +27,7 @@ type SessionSummary = { metadata: SessionSummaryMetadata | null todoProgress: { completed: number; total: number } | null pendingRequestsCount: number - modelMode?: 'default' | 'sonnet' | 'opus' | null + modelMode?: 'default' | 'sonnet' | 'opus' } function toSessionSummary(session: Session): SessionSummary { @@ -55,12 +55,12 @@ function toSessionSummary(session: Session): SessionSummary { metadata, todoProgress, pendingRequestsCount, - modelMode: session.modelMode ?? null + modelMode: session.modelMode } } const permissionModeSchema = z.object({ - mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']) + mode: z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan', 'read-only', 'safe-yolo', 'yolo']) }) const modelModeSchema = z.object({ @@ -159,8 +159,26 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ error: 'Invalid body' }, 400) } - await engine.setPermissionMode(sessionResult.sessionId, parsed.data.mode) - return c.json({ ok: true }) + const flavor = sessionResult.session.metadata?.flavor ?? 'claude' + const mode = parsed.data.mode + const claudeModes = new Set(['default', 'acceptEdits', 'bypassPermissions', 'plan']) + const codexModes = new Set(['default', 'read-only', 'safe-yolo', 'yolo']) + + if (flavor === 'gemini') { + return c.json({ error: 'Permission mode not supported for Gemini sessions' }, 400) + } + + if (flavor === 'codex' ? !codexModes.has(mode) : !claudeModes.has(mode)) { + return c.json({ error: 'Invalid permission mode for session flavor' }, 400) + } + + try { + await engine.applySessionConfig(sessionResult.sessionId, { permissionMode: mode }) + return c.json({ ok: true }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to apply permission mode' + return c.json({ error: message }, 409) + } }) app.post('/sessions/:id/model', async (c) => { @@ -180,8 +198,18 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ error: 'Invalid body' }, 400) } - await engine.setModelMode(sessionResult.sessionId, parsed.data.model) - return c.json({ ok: true }) + const flavor = sessionResult.session.metadata?.flavor ?? 'claude' + if (flavor !== 'claude') { + return c.json({ error: 'Model mode is only supported for Claude sessions' }, 400) + } + + try { + await engine.applySessionConfig(sessionResult.sessionId, { modelMode: parsed.data.model }) + return c.json({ ok: true }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to apply model mode' + return c.json({ error: message }, 409) + } }) app.get('/sessions/:id/slash-commands', async (c) => { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c9b88da6..a24a2508 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -179,7 +179,7 @@ export class ApiClient { }) } - async setPermissionMode(sessionId: string, mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'): Promise { + async setPermissionMode(sessionId: string, mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/permission-mode`, { method: 'POST', body: JSON.stringify({ mode }) diff --git a/web/src/chat/modelConfig.ts b/web/src/chat/modelConfig.ts index 39ef20fa..72099bde 100644 --- a/web/src/chat/modelConfig.ts +++ b/web/src/chat/modelConfig.ts @@ -12,17 +12,16 @@ import type { ModelMode } from '@/types/api' */ const CONTEXT_HEADROOM_TOKENS = 10_000 -const MODEL_CONTEXT_WINDOWS: Record, number> = { +const MODEL_CONTEXT_WINDOWS: Record = { // Claude Code modes used in this app; currently treated as ~200k context. default: 200_000, sonnet: 200_000, opus: 200_000 } -export function getContextBudgetTokens(modelMode: ModelMode): number | null { - const mode: NonNullable = modelMode ?? 'default' +export function getContextBudgetTokens(modelMode: ModelMode | undefined): number | null { + const mode: ModelMode = modelMode ?? 'default' const windowTokens = MODEL_CONTEXT_WINDOWS[mode] if (!windowTokens) return null return Math.max(1, windowTokens - CONTEXT_HEADROOM_TOKENS) } - diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 36d94352..ab04bde8 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -26,12 +26,16 @@ export interface TextInputState { selection: { start: number; end: number } } -const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const +const CLAUDE_PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const +const CODEX_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const const PERMISSION_MODE_LABELS: Record = { default: 'Default', acceptEdits: 'Accept Edits', plan: 'Plan Mode', - bypassPermissions: 'Bypass All' + bypassPermissions: 'Yolo', + 'read-only': 'Read Only', + 'safe-yolo': 'Safe Yolo', + yolo: 'Yolo' } const MODEL_MODES = ['default', 'sonnet', 'opus'] as const @@ -52,6 +56,7 @@ export function HappyComposer(props: { agentState?: AgentState | null contextSize?: number controlledByUser?: boolean + agentFlavor?: string | null onPermissionModeChange?: (mode: PermissionMode) => void onModelModeChange?: (mode: ModelMode) => void onSwitchToRemote?: () => void @@ -68,6 +73,7 @@ export function HappyComposer(props: { agentState, contextSize, controlledByUser = false, + agentFlavor, onPermissionModeChange, onModelModeChange, onSwitchToRemote, @@ -76,7 +82,7 @@ export function HappyComposer(props: { autocompleteSuggestions = defaultSuggestionHandler } = props - // Use ?? to handle both null and undefined (destructuring defaults only work for undefined) + // Use ?? so missing values fall back to default (destructuring defaults only handle undefined) const permissionMode = rawPermissionMode ?? 'default' const modelMode = rawModelMode ?? 'default' @@ -211,6 +217,16 @@ export function HappyComposer(props: { } }, [switchDisabled, onSwitchToRemote, haptic]) + const permissionModes = useMemo(() => { + if (agentFlavor === 'codex') { + return CODEX_PERMISSION_MODES as readonly PermissionMode[] + } + if (agentFlavor === 'gemini') { + return [] as readonly PermissionMode[] + } + return CLAUDE_PERMISSION_MODES as readonly PermissionMode[] + }, [agentFlavor]) + const handleKeyDown = useCallback((e: ReactKeyboardEvent) => { const key = e.key @@ -249,11 +265,12 @@ export function HappyComposer(props: { return } - if (key === 'Tab' && e.shiftKey && onPermissionModeChange) { + if (key === 'Tab' && e.shiftKey && onPermissionModeChange && permissionModes.length > 0) { e.preventDefault() - const currentIndex = PERMISSION_MODES.indexOf(permissionMode as typeof PERMISSION_MODES[number]) - const nextIndex = (currentIndex + 1) % PERMISSION_MODES.length - onPermissionModeChange(PERMISSION_MODES[nextIndex]) + const currentIndex = permissionModes.indexOf(permissionMode) + const nextIndex = (currentIndex + 1) % permissionModes.length + const nextMode = permissionModes[nextIndex] ?? 'default' + onPermissionModeChange(nextMode) haptic('light') } }, [ @@ -267,12 +284,13 @@ export function HappyComposer(props: { handleAbort, onPermissionModeChange, permissionMode, + permissionModes, haptic ]) useEffect(() => { const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => { - if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelModeChange) { + if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelModeChange && agentFlavor !== 'codex' && agentFlavor !== 'gemini') { e.preventDefault() const currentIndex = MODEL_MODES.indexOf(modelMode as typeof MODEL_MODES[number]) const nextIndex = (currentIndex + 1) % MODEL_MODES.length @@ -283,7 +301,7 @@ export function HappyComposer(props: { window.addEventListener('keydown', handleGlobalKeyDown) return () => window.removeEventListener('keydown', handleGlobalKeyDown) - }, [modelMode, onModelModeChange, haptic]) + }, [modelMode, onModelModeChange, haptic, agentFlavor]) const handleChange = useCallback((e: ReactChangeEvent) => { const selection = { @@ -324,9 +342,9 @@ export function HappyComposer(props: { haptic('light') }, [onModelModeChange, controlsDisabled, haptic]) - const showPermissionSettings = Boolean(onPermissionModeChange) - const showModelSettings = Boolean(onModelModeChange) - const showSettingsButton = Boolean(onPermissionModeChange || onModelModeChange) + const showPermissionSettings = Boolean(onPermissionModeChange && permissionModes.length > 0) + const showModelSettings = Boolean(onModelModeChange && agentFlavor !== 'codex' && agentFlavor !== 'gemini') + const showSettingsButton = Boolean(showPermissionSettings || showModelSettings) const showAbortButton = true const overlays = useMemo(() => { @@ -339,7 +357,7 @@ export function HappyComposer(props: {
Permission Mode
- {PERMISSION_MODES.map((mode) => ( + {permissionModes.map((mode) => (