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:
weishu
2025-12-28 20:51:18 +08:00
parent 2dc09a80db
commit 334cebc2da
21 changed files with 361 additions and 120 deletions
+32 -5
View File
@@ -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;
}
}
+20 -3
View File
@@ -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
View File
@@ -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'
+7 -1
View File
@@ -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
View File
@@ -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': {
+17 -1
View File
@@ -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
+3 -1
View File
@@ -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
View File
@@ -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) {
+9 -2
View File
@@ -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
View File
@@ -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 {