Files
hapi/cli/src/codex/codexLocal.ts
T
988f8f183f feat: settings toggle for AGENT_NOTIFY_SUMMARY contract injection (#1376)
* feat: settings toggle for AGENT_NOTIFY_SUMMARY contract injection

Add a hub-persisted, default-off Settings control so operators can opt agents into emitting the trailing AGENT_NOTIFY_SUMMARY line. Propagate the resolved flag on CLI session bootstrap and inject at call time for Claude, Codex, OpenCode, and Grok (Cursor still unsupported).

Closes #1375

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: lock hub settings RMW and restore abort deliveryMode

Serialize settings.json updates with the shared .lock protocol and unique
temp files so the new hub toggle cannot clobber CLI/relay fields under
concurrency. Also supply deliveryMode on abort send-error restore so web
typecheck (and CI) pass.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): wrap SettingsGeneralPage tests with QueryClientProvider

The hub-settings toggle uses TanStack Query; the settings page suite
was rendering without a QueryClient and blew up CI.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): defer summary-contract toggle until settings load

Avoid rendering an interactive false switch while the hub GET is still
in flight, which could overwrite an enabled preference on early click.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): clarify Grok coverage for summary-contract toggle

Local Grok has no instruction inject path; settings copy now matches
remote-only Grok support (and still excludes Cursor).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 18:51:35 +08:00

190 lines
5.4 KiB
TypeScript

import { logger } from '@/ui/logger';
import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard';
import {
buildMcpServerConfigArgs,
buildDeveloperInstructionsArg,
buildCodexHookConfigArgs,
buildModelReasoningEffortConfigArgs
} from './utils/codexMcpConfig';
import { getCodexSystemPrompt } from './utils/systemPrompt';
import type { ReasoningEffort } from './appServerTypes';
import { resolveCodexCommand } from './utils/codexExecutable';
import type { McpServersConfig } from './utils/buildHapiMcpBridge';
const CODEX_OPTIONS_WITH_VALUE = new Set([
'-a',
'--add-dir',
'--ask-for-approval',
'-C',
'--cd',
'-c',
'--config',
'--disable',
'--enable',
'--local-provider',
'-m',
'--model',
'-p',
'--profile',
'--remote',
'--remote-auth-token-env',
'-s',
'--sandbox'
]);
// -i/--image is intentionally omitted because it accepts a variable number of files.
function findResumeSubcommandIndex(args: string[]): number {
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--') {
return -1;
}
if (arg === 'resume') {
return i;
}
if (!arg.startsWith('-')) {
return -1;
}
if (!arg.includes('=') && CODEX_OPTIONS_WITH_VALUE.has(arg)) {
i += 1;
}
}
return -1;
}
function findResumeSessionIdIndex(args: string[], resumeIndex: number): number {
for (let i = resumeIndex + 1; i < args.length; i++) {
const arg = args[i];
if (arg === '--') {
return -1;
}
if (!arg.startsWith('-')) {
return i;
}
if (!arg.includes('=') && CODEX_OPTIONS_WITH_VALUE.has(arg)) {
i += 1;
}
}
return -1;
}
/**
* Filter out the Codex resume selector which is managed internally by hapi.
* Codex accepts global options before the subcommand, for example
* `codex --sandbox danger-full-access resume --last`.
*/
export function filterResumeSubcommand(args: string[]): string[] {
const resumeIndex = findResumeSubcommandIndex(args);
if (resumeIndex === -1) {
return args;
}
const optionTerminatorIndex = args.indexOf('--', resumeIndex + 1);
const lastIndex = args.findIndex((arg, index) => (
arg === '--last'
&& index > resumeIndex
&& (optionTerminatorIndex === -1 || index < optionTerminatorIndex)
));
const sessionIdIndex = lastIndex === -1
? findResumeSessionIdIndex(args, resumeIndex)
: -1;
const filtered = args.filter((_, index) => (
index !== resumeIndex
&& index !== lastIndex
&& index !== sessionIdIndex
));
if (lastIndex !== -1) {
logger.debug("[CodexLocal] Filtered 'resume --last' - session managed by hapi");
} else if (sessionIdIndex !== -1) {
logger.debug(`[CodexLocal] Filtered 'resume ${args[sessionIdIndex]}' - session managed by hapi`);
} else {
logger.debug("[CodexLocal] Filtered 'resume' - session managed by hapi");
}
return filtered;
}
export async function codexLocal(opts: {
abort: AbortSignal;
sessionId: string | null;
path: string;
model?: string;
modelReasoningEffort?: ReasoningEffort;
sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access';
onSessionFound: (id: string) => void;
codexArgs?: string[];
mcpServers?: McpServersConfig;
sessionHook?: {
port: number;
token: string;
};
}): Promise<void> {
const args: string[] = [];
if (opts.sessionId) {
args.push('resume', opts.sessionId);
opts.onSessionFound(opts.sessionId);
}
if (opts.model) {
args.push('--model', opts.model);
}
if (opts.modelReasoningEffort) {
args.push(...buildModelReasoningEffortConfigArgs(opts.modelReasoningEffort));
}
if (opts.sandbox) {
args.push('--sandbox', opts.sandbox);
}
// Add MCP server configuration
if (opts.mcpServers && Object.keys(opts.mcpServers).length > 0) {
args.push(...buildMcpServerConfigArgs(opts.mcpServers));
}
if (opts.sessionHook) {
args.push(...buildCodexHookConfigArgs(opts.sessionHook.port, opts.sessionHook.token));
}
// Add developer instructions (system prompt)
args.push(...buildDeveloperInstructionsArg(getCodexSystemPrompt()));
if (opts.codexArgs) {
// Before the first launch, Codex still needs the user's selector (for
// example `resume --last`). Once hapi has the concrete session ID, it
// prepends that ID above and removes the original selector here.
const safeArgs = opts.sessionId
? filterResumeSubcommand(opts.codexArgs)
: opts.codexArgs;
args.push(...safeArgs);
}
logger.debug(`[CodexLocal] Spawning codex with args: ${JSON.stringify(args)}`);
if (opts.abort.aborted) {
logger.debug('[CodexLocal] Abort already signaled before spawn; skipping launch');
return;
}
const codexCommand = resolveCodexCommand();
await spawnWithTerminalGuard({
command: codexCommand.command,
args: [...codexCommand.args, ...args],
cwd: opts.path,
env: process.env,
signal: opts.abort,
logLabel: 'CodexLocal',
spawnName: 'codex',
installHint: 'Codex CLI',
includeCause: true,
logExit: true
});
}