feat: refresh dashboard settings UX and Codex feature toggles (#153)
* feat: refresh dashboard settings UX and Codex feature toggles Improve settings/tasks mobile layout, model effort validation by agent type, and preset models for GPT 5.5 and Opus 4.7. Store Codex fast mode and goals in config.toml with Claude fastMode session sync and updated docs for Codex 0.133. Co-authored-by: Cursor <cursoragent@cursor.com> * style: apply pre-commit formatting after dashboard settings commit Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -577,6 +578,24 @@ describe('prepareGroupEnvironment Codex goals handling', () => {
|
||||
|
||||
expect(prepared.env.CODEX_GOALS).toBe('true');
|
||||
});
|
||||
|
||||
it('enables goals from host ~/.codex/config.toml [features]', () => {
|
||||
mockReadEnvFile.mockReturnValue({});
|
||||
const homedirSpy = vi
|
||||
.spyOn(os, 'homedir')
|
||||
.mockReturnValue(process.env.EJ_TEST_HOME!);
|
||||
fs.writeFileSync(
|
||||
path.join(process.env.EJ_TEST_HOME!, '.codex', 'config.toml'),
|
||||
'[features]\ngoals = true\n',
|
||||
);
|
||||
|
||||
try {
|
||||
const prepared = prepareGroupEnvironment(group, false, 'dc:test');
|
||||
expect(prepared.env.CODEX_GOALS).toBe('true');
|
||||
} finally {
|
||||
homedirSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareReadonlySessionEnvironment codex compatibility', () => {
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
import { logger } from './logger.js';
|
||||
import { readEnvFile } from './env.js';
|
||||
import { getActiveCodexAuthPath } from './codex-token-rotation.js';
|
||||
import { readCodexFeatureFromFile } from './codex-config-features.js';
|
||||
import { ensureClaudeSessionSettings } from './claude-session-settings.js';
|
||||
import {
|
||||
getConfiguredClaudeTokens,
|
||||
getCurrentToken,
|
||||
@@ -130,27 +132,7 @@ function readOptionalPromptFile(
|
||||
return prompt || undefined;
|
||||
}
|
||||
|
||||
function ensureClaudeSessionSettings(groupSessionsDir: string): void {
|
||||
const settingsFile = path.join(groupSessionsDir, 'settings.json');
|
||||
if (fs.existsSync(settingsFile)) return;
|
||||
|
||||
fs.writeFileSync(
|
||||
settingsFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
env: {
|
||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
|
||||
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + '\n',
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureClaudeGlobalSettingsFile(sessionDir: string): void {
|
||||
function ensureClaudeGlobalSettingsFile(sessionDir: string): void {
|
||||
const settingsFile = path.join(sessionDir, '.claude.json');
|
||||
if (fs.existsSync(settingsFile)) return;
|
||||
|
||||
@@ -387,15 +369,6 @@ function prepareCodexSessionEnvironment(args: {
|
||||
process.env.CODEX_EFFORT;
|
||||
if (codexEffort) args.env.CODEX_EFFORT = codexEffort;
|
||||
|
||||
const codexGoals =
|
||||
args.group.agentConfig?.codexGoals ??
|
||||
(args.envVars.CODEX_GOALS === 'true' || process.env.CODEX_GOALS === 'true');
|
||||
if (codexGoals) {
|
||||
args.env.CODEX_GOALS = 'true';
|
||||
} else {
|
||||
delete args.env.CODEX_GOALS;
|
||||
}
|
||||
|
||||
const sessionCodexDir = path.join(args.sessionRootDir, '.codex');
|
||||
syncHostCodexSessionFiles(sessionCodexDir);
|
||||
|
||||
@@ -414,6 +387,18 @@ function prepareCodexSessionEnvironment(args: {
|
||||
}
|
||||
}
|
||||
|
||||
const goalsFromConfig = readCodexFeatureFromFile(sessionConfigPath, 'goals');
|
||||
const codexGoals =
|
||||
args.group.agentConfig?.codexGoals ??
|
||||
(goalsFromConfig ||
|
||||
args.envVars.CODEX_GOALS === 'true' ||
|
||||
process.env.CODEX_GOALS === 'true');
|
||||
if (codexGoals) {
|
||||
args.env.CODEX_GOALS = 'true';
|
||||
} else {
|
||||
delete args.env.CODEX_GOALS;
|
||||
}
|
||||
|
||||
const sessionAgentsPath = path.join(sessionCodexDir, 'AGENTS.md');
|
||||
const sessionAgents = (
|
||||
args.useFailoverPromptPack
|
||||
|
||||
@@ -53,6 +53,7 @@ vi.mock('../service-routing.js', () => ({
|
||||
type Handler = (...args: any[]) => any;
|
||||
|
||||
const clientRef = vi.hoisted(() => ({ current: null as any }));
|
||||
const loginShouldRejectRef = vi.hoisted(() => ({ value: false }));
|
||||
|
||||
vi.mock('discord.js', () => {
|
||||
const Events = {
|
||||
@@ -89,6 +90,9 @@ vi.mock('discord.js', () => {
|
||||
}
|
||||
|
||||
async login(_token: string) {
|
||||
if (loginShouldRejectRef.value) {
|
||||
throw new Error('An invalid token was provided.');
|
||||
}
|
||||
this._ready = true;
|
||||
// Fire the ready event
|
||||
const readyHandlers = this.eventHandlers.get('ready') || [];
|
||||
@@ -233,6 +237,7 @@ describe('DiscordChannel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
hasReviewerLeaseMock.mockReturnValue(false);
|
||||
loginShouldRejectRef.value = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -263,6 +268,17 @@ describe('DiscordChannel', () => {
|
||||
expect(channel.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects connect() when login fails', async () => {
|
||||
loginShouldRejectRef.value = true;
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('bad-token', opts);
|
||||
|
||||
await expect(channel.connect()).rejects.toThrow(
|
||||
'An invalid token was provided.',
|
||||
);
|
||||
expect(channel.isConnected()).toBe(false);
|
||||
});
|
||||
|
||||
it('registers message handlers on connect', async () => {
|
||||
const opts = createTestOpts();
|
||||
const channel = new DiscordChannel('test-token', opts);
|
||||
|
||||
@@ -239,7 +239,7 @@ export class DiscordChannel implements Channel {
|
||||
logger.error({ err: err.message }, 'Discord client error');
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.client!.once(Events.ClientReady, (readyClient) => {
|
||||
logger.info(
|
||||
{ username: readyClient.user.tag, id: readyClient.user.id },
|
||||
@@ -252,7 +252,7 @@ export class DiscordChannel implements Channel {
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.client!.login(this.botToken);
|
||||
void this.client!.login(this.botToken).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
39
src/claude-session-settings.test.ts
Normal file
39
src/claude-session-settings.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { ensureClaudeSessionSettings } from './claude-session-settings.js';
|
||||
|
||||
describe('claude-session-settings', () => {
|
||||
let tempDir: string;
|
||||
let hostSettingsPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-claude-settings-'));
|
||||
hostSettingsPath = path.join(tempDir, 'host-settings.json');
|
||||
process.env.EJCLAW_CLAUDE_SETTINGS_PATH = hostSettingsPath;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EJCLAW_CLAUDE_SETTINGS_PATH;
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('syncs host fastMode into the Claude session settings file', () => {
|
||||
fs.writeFileSync(
|
||||
hostSettingsPath,
|
||||
`${JSON.stringify({ fastMode: true }, null, 2)}\n`,
|
||||
);
|
||||
|
||||
const sessionDir = path.join(tempDir, 'session');
|
||||
ensureClaudeSessionSettings(sessionDir);
|
||||
|
||||
const session = JSON.parse(
|
||||
fs.readFileSync(path.join(sessionDir, 'settings.json'), 'utf-8'),
|
||||
) as { fastMode?: boolean; env?: Record<string, string> };
|
||||
expect(session.fastMode).toBe(true);
|
||||
expect(session.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1');
|
||||
});
|
||||
});
|
||||
68
src/claude-session-settings.ts
Normal file
68
src/claude-session-settings.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const DEFAULT_CLAUDE_SESSION_ENV = {
|
||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
|
||||
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
|
||||
} as const;
|
||||
|
||||
function settingsHomeDir(): string {
|
||||
return process.env.EJCLAW_SETTINGS_HOME || os.homedir();
|
||||
}
|
||||
|
||||
export function claudeHostSettingsPath(): string {
|
||||
const override = process.env.EJCLAW_CLAUDE_SETTINGS_PATH?.trim();
|
||||
if (override) return override;
|
||||
return path.join(settingsHomeDir(), '.claude', 'settings.json');
|
||||
}
|
||||
|
||||
export function readHostClaudeFastMode(): boolean {
|
||||
const file = claudeHostSettingsPath();
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return data.fastMode === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureClaudeSessionSettings(groupSessionsDir: string): void {
|
||||
const settingsFile = path.join(groupSessionsDir, 'settings.json');
|
||||
let data: Record<string, unknown> = {
|
||||
env: { ...DEFAULT_CLAUDE_SESSION_ENV },
|
||||
};
|
||||
|
||||
if (fs.existsSync(settingsFile)) {
|
||||
try {
|
||||
const existing = JSON.parse(
|
||||
fs.readFileSync(settingsFile, 'utf-8'),
|
||||
) as Record<string, unknown>;
|
||||
data = {
|
||||
...existing,
|
||||
env: {
|
||||
...DEFAULT_CLAUDE_SESSION_ENV,
|
||||
...(typeof existing.env === 'object' && existing.env !== null
|
||||
? (existing.env as Record<string, unknown>)
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
data = { env: { ...DEFAULT_CLAUDE_SESSION_ENV } };
|
||||
}
|
||||
}
|
||||
|
||||
if (readHostClaudeFastMode()) {
|
||||
data.fastMode = true;
|
||||
} else {
|
||||
delete data.fastMode;
|
||||
}
|
||||
|
||||
fs.mkdirSync(groupSessionsDir, { recursive: true });
|
||||
fs.writeFileSync(settingsFile, `${JSON.stringify(data, null, 2)}\n`);
|
||||
}
|
||||
30
src/codex-config-features.test.ts
Normal file
30
src/codex-config-features.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
readCodexFeatureFromContent,
|
||||
writeCodexFeatureInContent,
|
||||
} from './codex-config-features.js';
|
||||
|
||||
describe('codex-config-features', () => {
|
||||
it('reads feature flags from the [features] section', () => {
|
||||
const toml = `
|
||||
model = "gpt-5.5"
|
||||
|
||||
[features]
|
||||
fast_mode = true
|
||||
goals = false
|
||||
`;
|
||||
expect(readCodexFeatureFromContent(toml, 'fast_mode')).toBe(true);
|
||||
expect(readCodexFeatureFromContent(toml, 'goals')).toBe(false);
|
||||
});
|
||||
|
||||
it('writes missing feature flags into [features]', () => {
|
||||
const updated = writeCodexFeatureInContent(
|
||||
'model = "gpt-5.5"\n',
|
||||
'goals',
|
||||
true,
|
||||
);
|
||||
expect(updated).toContain('[features]');
|
||||
expect(updated).toContain('goals = true');
|
||||
});
|
||||
});
|
||||
84
src/codex-config-features.ts
Normal file
84
src/codex-config-features.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
export type CodexConfigFeature = 'fast_mode' | 'goals';
|
||||
|
||||
export function codexConfigPath(): string {
|
||||
const override = process.env.EJCLAW_CODEX_CONFIG_PATH?.trim();
|
||||
if (override) return override;
|
||||
const home = process.env.EJCLAW_SETTINGS_HOME || os.homedir();
|
||||
return path.join(home, '.codex', 'config.toml');
|
||||
}
|
||||
|
||||
export function readCodexFeatureFromContent(
|
||||
content: string,
|
||||
feature: CodexConfigFeature,
|
||||
): boolean {
|
||||
const lines = content.split('\n');
|
||||
let inFeatures = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '[features]') {
|
||||
inFeatures = true;
|
||||
continue;
|
||||
}
|
||||
if (inFeatures && trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
break;
|
||||
}
|
||||
if (!inFeatures) continue;
|
||||
const match = trimmed.match(
|
||||
new RegExp(`^${feature}\\s*=\\s*(true|false)$`),
|
||||
);
|
||||
if (match) return match[1] === 'true';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function readCodexFeatureFromFile(
|
||||
filePath: string,
|
||||
feature: CodexConfigFeature,
|
||||
): boolean {
|
||||
if (!fs.existsSync(filePath)) return false;
|
||||
return readCodexFeatureFromContent(
|
||||
fs.readFileSync(filePath, 'utf-8'),
|
||||
feature,
|
||||
);
|
||||
}
|
||||
|
||||
export function writeCodexFeatureInContent(
|
||||
content: string,
|
||||
feature: CodexConfigFeature,
|
||||
value: boolean,
|
||||
): string {
|
||||
const line = `${feature} = ${value}`;
|
||||
const re = new RegExp(`^\\s*${feature}\\s*=\\s*(true|false)\\s*$`, 'm');
|
||||
if (re.test(content)) {
|
||||
return content.replace(re, line);
|
||||
}
|
||||
if (/^\[features\]/m.test(content)) {
|
||||
return content.replace(/^\[features\]\s*$/m, `[features]\n${line}`);
|
||||
}
|
||||
const trimmed = content.replace(/\s*$/, '');
|
||||
if (!trimmed) {
|
||||
return `[features]\n${line}\n`;
|
||||
}
|
||||
return `${trimmed}\n\n[features]\n${line}\n`;
|
||||
}
|
||||
|
||||
export function writeCodexFeatureToFile(
|
||||
filePath: string,
|
||||
feature: CodexConfigFeature,
|
||||
value: boolean,
|
||||
): void {
|
||||
const content = fs.existsSync(filePath)
|
||||
? fs.readFileSync(filePath, 'utf-8')
|
||||
: '';
|
||||
const updated = writeCodexFeatureInContent(content, feature, value);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
const tempPath = `${filePath}.tmp`;
|
||||
fs.writeFileSync(tempPath, updated, { mode: 0o600 });
|
||||
fs.renameSync(tempPath, filePath);
|
||||
}
|
||||
21
src/index.ts
21
src/index.ts
@@ -346,12 +346,25 @@ async function main(): Promise<void> {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
channels.push(channel);
|
||||
await channel.connect();
|
||||
try {
|
||||
await channel.connect();
|
||||
channels.push(channel);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ channel: channelName, err },
|
||||
'Channel connect failed — skipping',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (channels.length === 0) {
|
||||
logger.fatal('No channels connected');
|
||||
process.exit(1);
|
||||
if (WEB_DASHBOARD.enabled) {
|
||||
logger.warn(
|
||||
'No channels connected; continuing in web-dashboard-only mode',
|
||||
);
|
||||
} else {
|
||||
logger.fatal('No channels connected');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Start subsystems (independently of connection handler)
|
||||
|
||||
20
src/settings-effort.test.ts
Normal file
20
src/settings-effort.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { agentTypeForRole, isEffortSupported } from './settings-effort.js';
|
||||
|
||||
describe('settings-effort', () => {
|
||||
it('maps roles to agent types with defaults', () => {
|
||||
expect(agentTypeForRole('owner', {})).toBe('codex');
|
||||
expect(agentTypeForRole('reviewer', {})).toBe('claude-code');
|
||||
expect(agentTypeForRole('arbiter', {})).toBeNull();
|
||||
expect(agentTypeForRole('owner', { OWNER_AGENT_TYPE: 'claude-code' })).toBe(
|
||||
'claude-code',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects xhigh for Claude agents', () => {
|
||||
expect(isEffortSupported('claude-code', 'xhigh')).toBe(false);
|
||||
expect(isEffortSupported('codex', 'xhigh')).toBe(true);
|
||||
expect(isEffortSupported('claude-code', '')).toBe(true);
|
||||
});
|
||||
});
|
||||
64
src/settings-effort.ts
Normal file
64
src/settings-effort.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export type SettingsAgentType = 'claude-code' | 'codex';
|
||||
|
||||
export const CODEX_EFFORT_VALUES = [
|
||||
'',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
'max',
|
||||
] as const;
|
||||
|
||||
export const CLAUDE_EFFORT_VALUES = [
|
||||
'',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'max',
|
||||
] as const;
|
||||
|
||||
export type EffortValue = (typeof CODEX_EFFORT_VALUES)[number];
|
||||
|
||||
export function effortValuesForAgent(
|
||||
agentType: SettingsAgentType,
|
||||
): readonly EffortValue[] {
|
||||
return agentType === 'claude-code'
|
||||
? CLAUDE_EFFORT_VALUES
|
||||
: CODEX_EFFORT_VALUES;
|
||||
}
|
||||
|
||||
export function isEffortSupported(
|
||||
agentType: SettingsAgentType | null | undefined,
|
||||
effort: string,
|
||||
): boolean {
|
||||
if (!effort) return true;
|
||||
if (!agentType) return true;
|
||||
return effortValuesForAgent(agentType).includes(effort as EffortValue);
|
||||
}
|
||||
|
||||
export function readSettingsAgentType(
|
||||
value: string | undefined,
|
||||
): SettingsAgentType | null {
|
||||
if (value === 'claude-code' || value === 'codex') return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function agentTypeForRole(
|
||||
role: 'owner' | 'reviewer' | 'arbiter',
|
||||
env:
|
||||
| Record<string, string | undefined>
|
||||
| ((key: string) => string | undefined),
|
||||
): SettingsAgentType | null {
|
||||
const read = typeof env === 'function' ? env : (key: string) => env[key];
|
||||
const key =
|
||||
role === 'owner'
|
||||
? 'OWNER_AGENT_TYPE'
|
||||
: role === 'reviewer'
|
||||
? 'REVIEWER_AGENT_TYPE'
|
||||
: 'ARBITER_AGENT_TYPE';
|
||||
const raw = read(key);
|
||||
if (raw !== undefined) return readSettingsAgentType(raw);
|
||||
if (role === 'owner') return 'codex';
|
||||
if (role === 'reviewer') return 'claude-code';
|
||||
return null;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ describe('settings-store Codex features', () => {
|
||||
delete process.env.CODEX_GOALS;
|
||||
process.env.HOME = tempDir;
|
||||
process.env.EJCLAW_SETTINGS_HOME = tempDir;
|
||||
fs.mkdirSync(path.join(tempDir, '.codex'), { recursive: true });
|
||||
process.chdir(tempDir);
|
||||
});
|
||||
|
||||
@@ -59,17 +60,28 @@ describe('settings-store Codex features', () => {
|
||||
return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.`;
|
||||
}
|
||||
|
||||
it('stores the Codex goals opt-in in the EJClaw .env file', () => {
|
||||
it('stores Codex goals in ~/.codex/config.toml [features]', () => {
|
||||
expect(getCodexFeatures()).toEqual({ goals: false });
|
||||
|
||||
expect(updateCodexFeatures({ goals: true })).toEqual({ goals: true });
|
||||
expect(fs.readFileSync(path.join(tempDir, '.env'), 'utf-8')).toContain(
|
||||
'CODEX_GOALS=true',
|
||||
);
|
||||
expect(
|
||||
fs.readFileSync(path.join(tempDir, '.codex', 'config.toml'), 'utf-8'),
|
||||
).toContain('goals = true');
|
||||
|
||||
expect(updateCodexFeatures({ goals: false })).toEqual({ goals: false });
|
||||
expect(fs.readFileSync(path.join(tempDir, '.env'), 'utf-8')).toContain(
|
||||
'CODEX_GOALS=false',
|
||||
expect(
|
||||
fs.readFileSync(path.join(tempDir, '.codex', 'config.toml'), 'utf-8'),
|
||||
).toContain('goals = false');
|
||||
});
|
||||
|
||||
it('still honors legacy CODEX_GOALS=true until migrated', () => {
|
||||
fs.writeFileSync(path.join(tempDir, '.env'), 'CODEX_GOALS=true\n');
|
||||
expect(getCodexFeatures()).toEqual({ goals: true });
|
||||
|
||||
updateCodexFeatures({ goals: false });
|
||||
expect(getCodexFeatures()).toEqual({ goals: false });
|
||||
expect(fs.readFileSync(path.join(tempDir, '.env'), 'utf-8')).not.toContain(
|
||||
'CODEX_GOALS',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,12 @@ import {
|
||||
type CodexLiveStatus,
|
||||
type CodexLiveStatusSummary,
|
||||
} from './codex-live-status.js';
|
||||
import {
|
||||
readCodexFeatureFromFile,
|
||||
writeCodexFeatureToFile,
|
||||
} from './codex-config-features.js';
|
||||
import { readHostClaudeFastMode } from './claude-session-settings.js';
|
||||
import { agentTypeForRole, isEffortSupported } from './settings-effort.js';
|
||||
|
||||
export type {
|
||||
CodexAdditionalRateLimitSummary,
|
||||
@@ -62,10 +68,17 @@ export interface ModelRoleConfig {
|
||||
effort: string;
|
||||
}
|
||||
|
||||
export interface ModelAgentTypes {
|
||||
owner: 'claude-code' | 'codex';
|
||||
reviewer: 'claude-code' | 'codex';
|
||||
arbiter: 'claude-code' | 'codex' | null;
|
||||
}
|
||||
|
||||
export interface ModelConfigSnapshot {
|
||||
owner: ModelRoleConfig;
|
||||
reviewer: ModelRoleConfig;
|
||||
arbiter: ModelRoleConfig;
|
||||
agentTypes: ModelAgentTypes;
|
||||
}
|
||||
|
||||
export interface FastModeSnapshot {
|
||||
@@ -269,7 +282,19 @@ function readEnvOrProcess(key: string): string | undefined {
|
||||
}
|
||||
|
||||
export function getModelConfig(): ModelConfigSnapshot {
|
||||
const out: Partial<ModelConfigSnapshot> = {};
|
||||
const env = readEnvFile();
|
||||
const envLookup = (key: string): string | undefined => {
|
||||
const fromFile = pickEnvValue(env, key);
|
||||
if (fromFile !== undefined) return fromFile;
|
||||
return process.env[key];
|
||||
};
|
||||
const out: Partial<ModelConfigSnapshot> = {
|
||||
agentTypes: {
|
||||
owner: agentTypeForRole('owner', envLookup) ?? 'codex',
|
||||
reviewer: agentTypeForRole('reviewer', envLookup) ?? 'claude-code',
|
||||
arbiter: agentTypeForRole('arbiter', envLookup),
|
||||
},
|
||||
};
|
||||
for (const role of ROLE_KEYS) {
|
||||
const model = readEnvOrProcess(`${role}_MODEL`) ?? '';
|
||||
const effort = readEnvOrProcess(`${role}_EFFORT`) ?? '';
|
||||
@@ -308,6 +333,8 @@ export function updateModelConfig(
|
||||
if (fs.existsSync(file)) {
|
||||
content = fs.readFileSync(file, 'utf-8');
|
||||
}
|
||||
const envLookup = (key: string): string | undefined =>
|
||||
pickEnvValue(content, key) ?? process.env[key];
|
||||
|
||||
for (const role of ROLE_KEYS) {
|
||||
const update = (
|
||||
@@ -318,6 +345,19 @@ export function updateModelConfig(
|
||||
content = setOrInsertEnvLine(content, `${role}_MODEL`, update.model);
|
||||
}
|
||||
if (update.effort !== undefined) {
|
||||
const agentType = agentTypeForRole(
|
||||
role.toLowerCase() as 'owner' | 'reviewer' | 'arbiter',
|
||||
envLookup,
|
||||
);
|
||||
if (
|
||||
update.effort &&
|
||||
agentType &&
|
||||
!isEffortSupported(agentType, update.effort)
|
||||
) {
|
||||
throw new Error(
|
||||
`${role}_EFFORT=${update.effort} is not supported for ${agentType} agents`,
|
||||
);
|
||||
}
|
||||
content = setOrInsertEnvLine(content, `${role}_EFFORT`, update.effort);
|
||||
}
|
||||
}
|
||||
@@ -564,7 +604,7 @@ export function stopCodexAccountRefreshLoop(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function codexConfigPath(): string {
|
||||
function codexConfigFile(): string {
|
||||
return path.join(settingsHomeDir(), '.codex', 'config.toml');
|
||||
}
|
||||
|
||||
@@ -573,51 +613,15 @@ function claudeSettingsPath(): string {
|
||||
}
|
||||
|
||||
function readCodexFastMode(): boolean {
|
||||
const file = codexConfigPath();
|
||||
if (!fs.existsSync(file)) return false;
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
// [features] section, look for fast_mode = true|false
|
||||
const featuresMatch = content.match(/\[features\][\s\S]*?(?=^\[|$)/m);
|
||||
const block = featuresMatch ? featuresMatch[0] : content;
|
||||
const m = block.match(/^\s*fast_mode\s*=\s*(true|false)\s*$/m);
|
||||
return m ? m[1] === 'true' : false;
|
||||
return readCodexFeatureFromFile(codexConfigFile(), 'fast_mode');
|
||||
}
|
||||
|
||||
function writeCodexFastMode(value: boolean): void {
|
||||
const file = codexConfigPath();
|
||||
let content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
|
||||
if (/^\s*fast_mode\s*=\s*(true|false)\s*$/m.test(content)) {
|
||||
content = content.replace(
|
||||
/^\s*fast_mode\s*=\s*(true|false)\s*$/m,
|
||||
`fast_mode = ${value}`,
|
||||
);
|
||||
} else if (/^\[features\]/m.test(content)) {
|
||||
content = content.replace(
|
||||
/^\[features\]\s*$/m,
|
||||
`[features]\nfast_mode = ${value}`,
|
||||
);
|
||||
} else {
|
||||
const trimmed = content.replace(/\s*$/, '');
|
||||
content = `${trimmed}\n\n[features]\nfast_mode = ${value}\n`;
|
||||
}
|
||||
const tempPath = `${file}.tmp`;
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(tempPath, content, { mode: 0o600 });
|
||||
fs.renameSync(tempPath, file);
|
||||
writeCodexFeatureToFile(codexConfigFile(), 'fast_mode', value);
|
||||
}
|
||||
|
||||
function readClaudeFastMode(): boolean {
|
||||
const file = claudeSettingsPath();
|
||||
if (!fs.existsSync(file)) return false;
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(file, 'utf-8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return data.fastMode === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return readHostClaudeFastMode();
|
||||
}
|
||||
|
||||
function writeClaudeFastMode(value: boolean): void {
|
||||
@@ -658,19 +662,26 @@ export function updateFastMode(
|
||||
}
|
||||
|
||||
function readCodexGoals(): boolean {
|
||||
const fromConfig = readCodexFeatureFromFile(codexConfigFile(), 'goals');
|
||||
if (fromConfig) return true;
|
||||
// Legacy .env opt-in kept for backward compatibility until migrated.
|
||||
return readEnvOrProcess('CODEX_GOALS') === 'true';
|
||||
}
|
||||
|
||||
function writeCodexGoals(value: boolean): void {
|
||||
writeCodexFeatureToFile(codexConfigFile(), 'goals', value);
|
||||
|
||||
const file = envFilePath();
|
||||
const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
|
||||
const updated = setOrInsertEnvLine(
|
||||
content,
|
||||
'CODEX_GOALS',
|
||||
value ? 'true' : 'false',
|
||||
);
|
||||
if (!fs.existsSync(file)) return;
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
if (!/^CODEX_GOALS=.*$/m.test(content)) return;
|
||||
const updated = content
|
||||
.split('\n')
|
||||
.filter((line) => !/^CODEX_GOALS=.*$/.test(line))
|
||||
.join('\n')
|
||||
.replace(/\n+$/, '');
|
||||
const tempPath = `${file}.tmp`;
|
||||
fs.writeFileSync(tempPath, updated, { mode: 0o600 });
|
||||
fs.writeFileSync(tempPath, updated ? `${updated}\n` : '', { mode: 0o600 });
|
||||
fs.renameSync(tempPath, file);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,11 @@ const modelConfig: ModelConfigSnapshot = {
|
||||
owner: { model: 'gpt-5.4', effort: 'medium' },
|
||||
reviewer: { model: 'claude-sonnet', effort: 'high' },
|
||||
arbiter: { model: 'gpt-5.4', effort: 'high' },
|
||||
agentTypes: {
|
||||
owner: 'codex',
|
||||
reviewer: 'claude-code',
|
||||
arbiter: null,
|
||||
},
|
||||
};
|
||||
|
||||
const fastMode: FastModeSnapshot = { codex: true, claude: false };
|
||||
|
||||
@@ -149,7 +149,9 @@ async function handleModelSettingsRoute(
|
||||
try {
|
||||
return jsonResponse(deps.updateModelConfig(body));
|
||||
} catch (err) {
|
||||
return jsonResponse({ error: errorMessage(err) }, { status: 500 });
|
||||
const message = errorMessage(err);
|
||||
const status = message.includes('not supported') ? 400 : 500;
|
||||
return jsonResponse({ error: message }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user