mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* feat(codex): support /personality via in-session override Intercept /personality in the Codex slash layer, keep the value in CLI memory only, and forward it on thread/turn start when set. Unset means omit the field so Codex config/thread defaults apply—no Hub DB or web UI. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(codex): refuse fake /personality clear (sticky thread setting) turn/start.personality sticks for later turns; omitting the field does not restore config.toml. Drop default|auto|clear success paths and require an explicit friendly|pragmatic|none instead. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
import { CODEX_PERMISSION_MODES } from '@hapi/protocol/modes';
|
|
import type { CodexPermissionMode } from '@hapi/protocol/types';
|
|
import type { ReasoningEffort } from '../appServerTypes';
|
|
import type { CodexPersonality, EnhancedMode } from '../loop';
|
|
import type { SlashCommand } from '@/modules/common/slashCommands';
|
|
import { parseReasoningEffortValue } from './reasoningEffort';
|
|
|
|
export const MAX_CODEX_GOAL_OBJECTIVE_CHARS = 4_000;
|
|
|
|
const CODEX_PERSONALITIES = ['friendly', 'pragmatic', 'none'] as const satisfies readonly CodexPersonality[];
|
|
|
|
const UNSUPPORTED_CODEX_BUILTIN_COMMANDS = new Set([
|
|
'compat',
|
|
'diff',
|
|
'init',
|
|
'login',
|
|
'logout',
|
|
'mcp',
|
|
'new',
|
|
'prompts',
|
|
'quit',
|
|
'redo',
|
|
'review',
|
|
'undo'
|
|
]);
|
|
|
|
export type CodexSlashResolution =
|
|
| { kind: 'passthrough' }
|
|
| {
|
|
kind: 'handled';
|
|
message: string;
|
|
updates?: {
|
|
collaborationMode?: EnhancedMode['collaborationMode'];
|
|
permissionMode?: CodexPermissionMode;
|
|
model?: string | null;
|
|
modelReasoningEffort?: ReasoningEffort | null;
|
|
serviceTier?: string | null;
|
|
proactiveMultiAgent?: boolean;
|
|
personality?: CodexPersonality;
|
|
};
|
|
}
|
|
| {
|
|
kind: 'replace';
|
|
text: string;
|
|
message?: string;
|
|
updates?: {
|
|
collaborationMode?: EnhancedMode['collaborationMode'];
|
|
permissionMode?: CodexPermissionMode;
|
|
model?: string | null;
|
|
modelReasoningEffort?: ReasoningEffort | null;
|
|
serviceTier?: string | null;
|
|
proactiveMultiAgent?: boolean;
|
|
personality?: CodexPersonality;
|
|
};
|
|
}
|
|
| {
|
|
kind: 'goal';
|
|
action: 'show' | 'set' | 'pause' | 'resume' | 'clear';
|
|
objective?: string;
|
|
message?: string;
|
|
};
|
|
|
|
export function resolveCodexSlashCommand(
|
|
text: string,
|
|
state: {
|
|
commands?: readonly SlashCommand[];
|
|
permissionMode: CodexPermissionMode;
|
|
collaborationMode: EnhancedMode['collaborationMode'];
|
|
model?: string;
|
|
modelReasoningEffort?: ReasoningEffort;
|
|
serviceTier?: string | null;
|
|
proactiveMultiAgent?: boolean;
|
|
personality?: CodexPersonality;
|
|
}
|
|
): CodexSlashResolution {
|
|
const match = /^\s*\/([a-z0-9:_-]+)(?:\s+([\s\S]*))?$/i.exec(text);
|
|
if (!match) return { kind: 'passthrough' };
|
|
|
|
const command = match[1]?.toLowerCase();
|
|
const rest = match[2]?.trim() ?? '';
|
|
if (!command) return { kind: 'passthrough' };
|
|
|
|
const custom = state.commands?.find((candidate) =>
|
|
candidate.source !== 'builtin' && candidate.name.toLowerCase() === command
|
|
);
|
|
if (custom?.content) {
|
|
return {
|
|
kind: 'replace',
|
|
text: rest ? `${custom.content}\n\nUser arguments: ${rest}` : custom.content,
|
|
message: `Expanded /${custom.name}`
|
|
};
|
|
}
|
|
|
|
if (command === 'plan') {
|
|
const lowerRest = rest.toLowerCase();
|
|
if (lowerRest === 'off' || lowerRest === 'default' || lowerRest === 'exit' || lowerRest === 'disable') {
|
|
return {
|
|
kind: 'handled',
|
|
message: 'Codex plan mode disabled',
|
|
updates: { collaborationMode: 'default' }
|
|
};
|
|
}
|
|
if (rest) {
|
|
return {
|
|
kind: 'replace',
|
|
text: rest,
|
|
message: 'Codex plan mode enabled',
|
|
updates: { collaborationMode: 'plan' }
|
|
};
|
|
}
|
|
return {
|
|
kind: 'handled',
|
|
message: 'Codex plan mode enabled',
|
|
updates: { collaborationMode: 'plan' }
|
|
};
|
|
}
|
|
|
|
if (command === 'agent') {
|
|
const value = rest.toLowerCase();
|
|
if (value === 'status') {
|
|
return {
|
|
kind: 'handled',
|
|
message: `Codex proactive multi-agent mode: ${state.proactiveMultiAgent ? 'on' : 'off'}`
|
|
};
|
|
}
|
|
if (value && !['on', 'enable', 'enabled', 'off', 'disable', 'disabled'].includes(value)) {
|
|
return {
|
|
kind: 'handled',
|
|
message: 'Usage: /agent [on|off|status]'
|
|
};
|
|
}
|
|
const enabled = value
|
|
? ['on', 'enable', 'enabled'].includes(value)
|
|
: !state.proactiveMultiAgent;
|
|
return {
|
|
kind: 'handled',
|
|
message: `Codex proactive multi-agent mode ${enabled ? 'enabled' : 'disabled'}`,
|
|
updates: { proactiveMultiAgent: enabled }
|
|
};
|
|
}
|
|
|
|
if (command === 'goal') {
|
|
const lowerRest = rest.toLowerCase();
|
|
if (!rest) {
|
|
return { kind: 'goal', action: 'show' };
|
|
}
|
|
if (lowerRest === 'clear') {
|
|
return { kind: 'goal', action: 'clear' };
|
|
}
|
|
if (lowerRest === 'pause') {
|
|
return { kind: 'goal', action: 'pause' };
|
|
}
|
|
if (lowerRest === 'resume') {
|
|
return { kind: 'goal', action: 'resume' };
|
|
}
|
|
const objective = rest.trim();
|
|
if (!objective) {
|
|
return {
|
|
kind: 'handled',
|
|
message: 'Goal objective must not be empty.'
|
|
};
|
|
}
|
|
if ([...objective].length > MAX_CODEX_GOAL_OBJECTIVE_CHARS) {
|
|
return {
|
|
kind: 'handled',
|
|
message: `Goal objective must be at most ${MAX_CODEX_GOAL_OBJECTIVE_CHARS} characters.`
|
|
};
|
|
}
|
|
return {
|
|
kind: 'goal',
|
|
action: 'set',
|
|
objective
|
|
};
|
|
}
|
|
|
|
if (command === 'default' || command === 'execute') {
|
|
return {
|
|
kind: 'handled',
|
|
message: 'Codex collaboration mode: default',
|
|
updates: { collaborationMode: 'default' }
|
|
};
|
|
}
|
|
|
|
if (command === 'status') {
|
|
return {
|
|
kind: 'handled',
|
|
message: [
|
|
'**Codex status**',
|
|
'',
|
|
`- permission: \`${state.permissionMode}\``,
|
|
`- collaboration: \`${state.collaborationMode}\``,
|
|
`- model: \`${state.model ?? 'auto'}\``,
|
|
`- reasoning: \`${state.modelReasoningEffort ?? 'default'}\``,
|
|
`- personality: \`${state.personality ?? 'unset'}\``
|
|
].join('\n')
|
|
};
|
|
}
|
|
|
|
if (command === 'personality') {
|
|
if (!rest) {
|
|
return {
|
|
kind: 'handled',
|
|
message: `Codex personality: ${state.personality ?? 'unset (Codex config / thread sticky)'}`
|
|
};
|
|
}
|
|
// turn/start.personality sticks for subsequent turns; omitting later does not
|
|
// restore config.toml. Only explicit friendly|pragmatic|none are valid.
|
|
if (rest === 'default' || rest === 'auto' || rest === 'clear') {
|
|
return {
|
|
kind: 'handled',
|
|
message: 'Codex personality is sticky on the thread; set friendly, pragmatic, or none (cannot restore config.toml by clearing)'
|
|
};
|
|
}
|
|
if (!(CODEX_PERSONALITIES as readonly string[]).includes(rest)) {
|
|
return {
|
|
kind: 'handled',
|
|
message: `Unknown Codex personality: ${rest}`
|
|
};
|
|
}
|
|
return {
|
|
kind: 'handled',
|
|
message: `Codex personality set to ${rest}`,
|
|
updates: { personality: rest as CodexPersonality }
|
|
};
|
|
}
|
|
|
|
if (command === 'model') {
|
|
if (!rest) {
|
|
return { kind: 'handled', message: `Codex model: ${state.model ?? 'auto'}` };
|
|
}
|
|
const model = rest === 'auto' ? null : rest;
|
|
return {
|
|
kind: 'handled',
|
|
message: `Codex model set to ${model ?? 'auto'}`,
|
|
updates: { model }
|
|
};
|
|
}
|
|
|
|
if (command === 'reasoning' || command === 'effort') {
|
|
if (!rest) {
|
|
return { kind: 'handled', message: `Codex reasoning effort: ${state.modelReasoningEffort ?? 'default'}` };
|
|
}
|
|
if (rest === 'default' || rest === 'auto') {
|
|
return {
|
|
kind: 'handled',
|
|
message: 'Codex reasoning effort set to default',
|
|
updates: { modelReasoningEffort: null }
|
|
};
|
|
}
|
|
const effort = parseReasoningEffortValue(rest);
|
|
return {
|
|
kind: 'handled',
|
|
message: `Codex reasoning effort set to ${effort}`,
|
|
updates: { modelReasoningEffort: effort }
|
|
};
|
|
}
|
|
|
|
if (command === 'fast') {
|
|
const arg = rest.toLowerCase();
|
|
if (arg === '' || arg === 'on') {
|
|
return {
|
|
kind: 'handled',
|
|
message: 'Codex Fast mode enabled',
|
|
updates: { serviceTier: 'fast' }
|
|
};
|
|
}
|
|
if (arg === 'off') {
|
|
return {
|
|
kind: 'handled',
|
|
message: 'Codex Fast mode disabled',
|
|
updates: { serviceTier: 'standard' }
|
|
};
|
|
}
|
|
if (arg === 'status') {
|
|
const on = state.serviceTier === 'fast';
|
|
return {
|
|
kind: 'handled',
|
|
message: `Codex Fast mode: ${on ? 'on' : 'off'}`
|
|
};
|
|
}
|
|
return {
|
|
kind: 'handled',
|
|
message: 'Usage: /fast [on|off|status]'
|
|
};
|
|
}
|
|
|
|
if (command === 'permissions' || command === 'permission') {
|
|
if (!rest) {
|
|
return { kind: 'handled', message: `Codex permission mode: ${state.permissionMode}` };
|
|
}
|
|
if (!(CODEX_PERMISSION_MODES as readonly string[]).includes(rest)) {
|
|
return {
|
|
kind: 'handled',
|
|
message: `Unknown Codex permission mode: ${rest}`
|
|
};
|
|
}
|
|
return {
|
|
kind: 'handled',
|
|
message: `Codex permission mode set to ${rest}`,
|
|
updates: { permissionMode: rest as CodexPermissionMode }
|
|
};
|
|
}
|
|
|
|
if (command === 'help') {
|
|
return {
|
|
kind: 'handled',
|
|
message: [
|
|
'**Supported Codex slash commands**',
|
|
'',
|
|
'- `/plan [prompt]` — enable plan mode, optionally send prompt',
|
|
'- `/plan off` — return to default mode',
|
|
'- `/goal [objective]` — set or view the persistent goal',
|
|
'- `/goal pause|resume|clear` — update the current goal',
|
|
'- `/clear` — reset current Codex thread context',
|
|
'- `/compact` — compact current Codex thread context',
|
|
'- `/status` — show current Codex session config',
|
|
'- `/model [name|auto]` — show or set model',
|
|
'- `/reasoning [level|default]` — show or set reasoning effort',
|
|
'- `/personality [friendly|pragmatic|none]` — show or set response style (sticky on thread)',
|
|
'- `/fast [on|off|status]` — toggle Fast mode (GPT-5.5 / GPT-5.4, ChatGPT login)',
|
|
'- `/permissions [default|read-only|safe-yolo|yolo]` — show or set permission mode',
|
|
'',
|
|
'Custom `/commands` from `.codex/prompts` are expanded before sending.'
|
|
].join('\n')
|
|
};
|
|
}
|
|
|
|
if (UNSUPPORTED_CODEX_BUILTIN_COMMANDS.has(command)) {
|
|
return {
|
|
kind: 'handled',
|
|
message: `/${command} is a Codex CLI command that is not supported in HAPI sessions yet.`
|
|
};
|
|
}
|
|
|
|
return { kind: 'passthrough' };
|
|
}
|