mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat: add real-time permission and model mode synchronization close #21
Implements bidirectional sync of permission/model modes between CLI sessions and web app. Adds Codex-specific permission modes (read-only, safe-yolo, yolo) alongside Claude's modes. Web can now control CLI session state via RPC set-session-config handler, while CLI broadcasts state changes through keep-alive payloads. UI controls are flavor-aware, showing appropriate modes for Claude vs Codex vs Gemini. Type centralization in api/types eliminates circular dependencies.
This commit is contained in:
@@ -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<Mode> = {
|
||||
@@ -15,6 +15,8 @@ export type AgentSessionBaseOptions<Mode> = {
|
||||
sessionLabel: string;
|
||||
sessionIdLabel: string;
|
||||
applySessionIdToMetadata: (metadata: Metadata, sessionId: string) => Metadata;
|
||||
permissionMode?: SessionPermissionMode;
|
||||
modelMode?: SessionModelMode;
|
||||
};
|
||||
|
||||
export class AgentSessionBase<Mode> {
|
||||
@@ -34,6 +36,8 @@ export class AgentSessionBase<Mode> {
|
||||
private readonly sessionLabel: string;
|
||||
private readonly sessionIdLabel: string;
|
||||
private keepAliveInterval: NodeJS.Timeout | null = null;
|
||||
protected permissionMode?: SessionPermissionMode;
|
||||
protected modelMode?: SessionModelMode;
|
||||
|
||||
constructor(opts: AgentSessionBaseOptions<Mode>) {
|
||||
this.path = opts.path;
|
||||
@@ -47,21 +51,26 @@ export class AgentSessionBase<Mode> {
|
||||
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<Mode> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ?? {})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+15
-6
@@ -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<typeof UsageSchema>
|
||||
|
||||
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<typeof CreateMachineResponseSchema>
|
||||
|
||||
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'
|
||||
|
||||
@@ -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
|
||||
|
||||
+55
-30
@@ -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<void> {
|
||||
}));
|
||||
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
|
||||
// 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<void> {
|
||||
|
||||
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<void> {
|
||||
},
|
||||
onSessionReady: (sessionInstance) => {
|
||||
currentSessionRef.current = sessionInstance;
|
||||
syncSessionModes();
|
||||
},
|
||||
mcpServers: {
|
||||
'hapi': {
|
||||
|
||||
@@ -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<EnhancedMode> {
|
||||
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<EnhancedMode> {
|
||||
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<EnhancedMode> {
|
||||
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 };
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
startedBy,
|
||||
startingMode,
|
||||
codexArgs: opts.codexArgs,
|
||||
codexCliOverrides: opts.codexCliOverrides
|
||||
codexCliOverrides: opts.codexCliOverrides,
|
||||
permissionMode: opts.permissionMode ?? 'default'
|
||||
});
|
||||
|
||||
if (opts.onSessionReady) {
|
||||
|
||||
+35
-28
@@ -25,6 +25,7 @@ export { emitReadyIfIdle } from './utils/emitReadyIfIdle';
|
||||
export async function runCodex(opts: {
|
||||
startedBy?: 'daemon' | 'terminal';
|
||||
codexArgs?: string[];
|
||||
permissionMode?: PermissionMode;
|
||||
}): Promise<void> {
|
||||
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) {
|
||||
|
||||
@@ -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<EnhancedMode> {
|
||||
startingMode: 'local' | 'remote';
|
||||
codexArgs?: string[];
|
||||
codexCliOverrides?: CodexCliOverrides;
|
||||
permissionMode?: PermissionMode;
|
||||
}) {
|
||||
super({
|
||||
api: opts.api,
|
||||
@@ -45,15 +46,21 @@ export class CodexSession extends AgentSessionBase<EnhancedMode> {
|
||||
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 };
|
||||
};
|
||||
|
||||
+12
-1
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -179,7 +179,7 @@ export class ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
async setPermissionMode(sessionId: string, mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'): Promise<void> {
|
||||
async setPermissionMode(sessionId: string, mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'): Promise<void> {
|
||||
await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/permission-mode`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode })
|
||||
|
||||
@@ -12,17 +12,16 @@ import type { ModelMode } from '@/types/api'
|
||||
*/
|
||||
const CONTEXT_HEADROOM_TOKENS = 10_000
|
||||
|
||||
const MODEL_CONTEXT_WINDOWS: Record<NonNullable<ModelMode>, number> = {
|
||||
const MODEL_CONTEXT_WINDOWS: Record<ModelMode, number> = {
|
||||
// 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> = 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<HTMLTextAreaElement>) => {
|
||||
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<HTMLTextAreaElement>) => {
|
||||
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: {
|
||||
<div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]">
|
||||
Permission Mode
|
||||
</div>
|
||||
{PERMISSION_MODES.map((mode) => (
|
||||
{permissionModes.map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
@@ -440,6 +458,7 @@ export function HappyComposer(props: {
|
||||
controlsDisabled,
|
||||
permissionMode,
|
||||
modelMode,
|
||||
permissionModes,
|
||||
handlePermissionChange,
|
||||
handleModelChange,
|
||||
handleSuggestionSelect
|
||||
@@ -458,6 +477,7 @@ export function HappyComposer(props: {
|
||||
contextSize={contextSize}
|
||||
modelMode={modelMode}
|
||||
permissionMode={permissionMode}
|
||||
agentFlavor={agentFlavor}
|
||||
/>
|
||||
|
||||
<div className="overflow-hidden rounded-[20px] bg-[var(--app-secondary-bg)]">
|
||||
|
||||
@@ -6,7 +6,10 @@ const PERMISSION_MODE_LABELS: Record<string, string> = {
|
||||
default: 'Default',
|
||||
acceptEdits: 'Accept Edits',
|
||||
plan: 'Plan Mode',
|
||||
bypassPermissions: 'Bypass All'
|
||||
bypassPermissions: 'Yolo',
|
||||
'read-only': 'Read Only',
|
||||
'safe-yolo': 'Safe Yolo',
|
||||
yolo: 'Yolo'
|
||||
}
|
||||
|
||||
// Vibing messages for thinking state
|
||||
@@ -91,6 +94,7 @@ export function StatusBar(props: {
|
||||
contextSize?: number
|
||||
modelMode?: ModelMode
|
||||
permissionMode?: PermissionMode
|
||||
agentFlavor?: string | null
|
||||
}) {
|
||||
const connectionStatus = useMemo(
|
||||
() => getConnectionStatus(props.active, props.thinking, props.agentState),
|
||||
@@ -108,6 +112,9 @@ export function StatusBar(props: {
|
||||
)
|
||||
|
||||
const permissionMode = props.permissionMode
|
||||
const shouldShowPermissionMode = props.agentFlavor !== 'gemini'
|
||||
&& permissionMode
|
||||
&& permissionMode !== 'default'
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2 pb-1">
|
||||
@@ -127,11 +134,14 @@ export function StatusBar(props: {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{(permissionMode && permissionMode !== 'default') ? (
|
||||
{shouldShowPermissionMode ? (
|
||||
<span className={`text-xs ${
|
||||
permissionMode === 'acceptEdits' ? 'text-amber-500' :
|
||||
permissionMode === 'bypassPermissions' ? 'text-red-500' :
|
||||
permissionMode === 'plan' ? 'text-blue-500' :
|
||||
permissionMode === 'read-only' ? 'text-amber-500' :
|
||||
permissionMode === 'safe-yolo' ? 'text-amber-500' :
|
||||
permissionMode === 'yolo' ? 'text-red-500' :
|
||||
'text-[var(--app-hint)]'
|
||||
}`}>
|
||||
{PERMISSION_MODE_LABELS[permissionMode]}
|
||||
|
||||
@@ -177,6 +177,7 @@ export function SessionChat(props: {
|
||||
disabled={props.isSending || controlsDisabled}
|
||||
permissionMode={props.session.permissionMode}
|
||||
modelMode={props.session.modelMode}
|
||||
agentFlavor={props.session.metadata?.flavor ?? 'claude'}
|
||||
active={props.session.active}
|
||||
thinking={props.session.thinking}
|
||||
agentState={props.session.agentState}
|
||||
|
||||
@@ -3,11 +3,11 @@ import type { ApiClient } from '@/api/client'
|
||||
import type { ModelMode, PermissionMode } from '@/types/api'
|
||||
import { queryKeys } from '@/lib/query-keys'
|
||||
|
||||
type PermissionModeValue = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'
|
||||
type PermissionModeValue = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'
|
||||
type ModelModeValue = 'default' | 'sonnet' | 'opus'
|
||||
|
||||
function toPermissionMode(mode: PermissionMode): PermissionModeValue {
|
||||
if (mode === 'acceptEdits' || mode === 'bypassPermissions' || mode === 'plan') {
|
||||
if (mode === 'acceptEdits' || mode === 'bypassPermissions' || mode === 'plan' || mode === 'read-only' || mode === 'safe-yolo' || mode === 'yolo') {
|
||||
return mode
|
||||
}
|
||||
return 'default'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | null | undefined
|
||||
export type ModelMode = 'default' | 'sonnet' | 'opus' | null | undefined
|
||||
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'
|
||||
export type ModelMode = 'default' | 'sonnet' | 'opus'
|
||||
|
||||
export type WorktreeMetadata = {
|
||||
basePath: string
|
||||
|
||||
Reference in New Issue
Block a user