remote-codex 0.1.9 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/apps/supervisor-api/dist/index.js +11942 -6101
  2. package/apps/supervisor-web/dist/assets/{highlighted-body-OFNGDK62-BFD4Ytvg.js → highlighted-body-OFNGDK62-ChrwAL9u.js} +1 -1
  3. package/apps/supervisor-web/dist/assets/index-DHf2HOXx.js +381 -0
  4. package/apps/supervisor-web/dist/assets/index-DpWxXCgt.css +32 -0
  5. package/apps/supervisor-web/dist/assets/{xterm-CukFWbxr.js → xterm-D4sevve4.js} +1 -1
  6. package/apps/supervisor-web/dist/index.html +2 -2
  7. package/config/codex-model-pricing.json +63 -0
  8. package/package.json +5 -2
  9. package/packages/agent-runtime/src/index.ts +4 -0
  10. package/packages/agent-runtime/src/management-errors.ts +11 -0
  11. package/packages/agent-runtime/src/model-pricing.ts +312 -0
  12. package/packages/agent-runtime/src/registry.ts +19 -4
  13. package/packages/agent-runtime/src/runtime-errors.ts +97 -0
  14. package/packages/agent-runtime/src/types.ts +50 -4
  15. package/packages/agent-runtime/src/unavailable-runtime.ts +169 -0
  16. package/packages/claude/src/historyItems.ts +693 -0
  17. package/packages/claude/src/index.ts +2 -0
  18. package/packages/claude/src/runtimeAdapter.test.ts +2138 -0
  19. package/packages/claude/src/runtimeAdapter.ts +2145 -0
  20. package/packages/codex/src/appServerManager.ts +12 -3
  21. package/packages/codex/src/historyItems.test.ts +110 -0
  22. package/packages/codex/src/historyItems.ts +97 -16
  23. package/packages/codex/src/hookHistory.test.ts +59 -0
  24. package/packages/codex/src/index.ts +7 -0
  25. package/packages/codex/src/local-session-store.ts +390 -0
  26. package/packages/codex/src/management/codex-management-service.ts +454 -0
  27. package/packages/codex/src/management/codexHostConfig.test.ts +88 -0
  28. package/packages/codex/src/management/codexHostConfig.ts +188 -0
  29. package/packages/codex/src/management/errors.ts +20 -0
  30. package/packages/codex/src/modelPricing.test.ts +184 -0
  31. package/packages/codex/src/modelPricing.ts +9 -0
  32. package/packages/codex/src/runtime-errors.test.ts +72 -0
  33. package/packages/codex/src/runtime-errors.ts +37 -0
  34. package/packages/codex/src/runtimeAdapter.ts +25 -2
  35. package/packages/codex/src/thread-title.ts +1 -0
  36. package/packages/db/src/repositories.ts +30 -0
  37. package/packages/opencode/src/historyItems.test.ts +504 -0
  38. package/packages/opencode/src/historyItems.ts +896 -0
  39. package/packages/opencode/src/index.ts +2 -0
  40. package/packages/opencode/src/runtimeAdapter.test.ts +1355 -0
  41. package/packages/opencode/src/runtimeAdapter.ts +1469 -0
  42. package/packages/shared/src/agent-providers.ts +56 -0
  43. package/packages/shared/src/index.ts +174 -35
  44. package/apps/supervisor-web/dist/assets/index-CbIt0KnL.css +0 -32
  45. package/apps/supervisor-web/dist/assets/index-Rd2EBQac.js +0 -377
@@ -0,0 +1,454 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import type { AgentRuntime } from '../../../agent-runtime/src/index';
5
+ import type {
6
+ AgentHookDto,
7
+ AgentHookEventNameDto,
8
+ CreateThreadHookInput,
9
+ UpdateThreadHookInput,
10
+ } from '../../../shared/src/index';
11
+ import {
12
+ readCodexFastModeSync,
13
+ readCodexFeatureFlag,
14
+ writeCodexFeatureFlag,
15
+ writeCodexFastMode,
16
+ } from './codexHostConfig';
17
+ import {
18
+ CodexManagementError,
19
+ codexBadRequest,
20
+ } from './errors';
21
+ import {
22
+ isUnsupportedHooksListError,
23
+ isCodexRuntimeRequestError,
24
+ unwrapCodexJsonRpcError,
25
+ } from '../runtime-errors';
26
+
27
+ const GOAL_FEATURE_DISABLED_MESSAGE =
28
+ 'Codex /goal is experimental. Enable it by adding `goals = true` under `[features]` in ~/.codex/config.toml, then restart the Codex app-server.';
29
+
30
+ const HOOK_EVENT_JSON_KEYS = {
31
+ preToolUse: 'PreToolUse',
32
+ permissionRequest: 'PermissionRequest',
33
+ postToolUse: 'PostToolUse',
34
+ preCompact: 'PreCompact',
35
+ postCompact: 'PostCompact',
36
+ sessionStart: 'SessionStart',
37
+ userPromptSubmit: 'UserPromptSubmit',
38
+ stop: 'Stop',
39
+ } as const;
40
+ const HOOK_EVENT_DTO_KEYS = Object.fromEntries(
41
+ Object.entries(HOOK_EVENT_JSON_KEYS).map(([dtoKey, jsonKey]) => [jsonKey, dtoKey]),
42
+ ) as Record<string, AgentHookEventNameDto>;
43
+
44
+ function isRecord(value: unknown): value is Record<string, unknown> {
45
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
46
+ }
47
+
48
+ function normalizeHooksJson(value: unknown): { hooks: Record<string, unknown[]> } & Record<string, unknown> {
49
+ if (!isRecord(value) || !isRecord(value.hooks)) {
50
+ return { hooks: {} as Record<string, unknown[]> };
51
+ }
52
+
53
+ const hooks: Record<string, unknown[]> = {};
54
+ for (const [eventName, groups] of Object.entries(value.hooks)) {
55
+ hooks[eventName] = Array.isArray(groups) ? groups : [];
56
+ }
57
+ return { ...value, hooks };
58
+ }
59
+
60
+ function readJsonFileOrDefault(
61
+ filePath: string,
62
+ ): Promise<{ hooks: Record<string, unknown[]> } & Record<string, unknown>> {
63
+ return fs
64
+ .readFile(filePath, 'utf8')
65
+ .then((raw) => {
66
+ if (!raw.trim()) {
67
+ return { hooks: {} as Record<string, unknown[]> };
68
+ }
69
+ return normalizeHooksJson(JSON.parse(raw));
70
+ })
71
+ .catch((error) => {
72
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
73
+ return { hooks: {} as Record<string, unknown[]> };
74
+ }
75
+ throw error;
76
+ });
77
+ }
78
+
79
+ function validateHookInput(input: CreateThreadHookInput) {
80
+ if (!HOOK_EVENT_JSON_KEYS[input.eventName]) {
81
+ codexBadRequest('Unsupported hook event.');
82
+ }
83
+ if (input.scope !== 'global' && input.scope !== 'project') {
84
+ codexBadRequest('Hook scope must be global or project.');
85
+ }
86
+ if (!input.command.trim()) {
87
+ codexBadRequest('Hook command cannot be empty.');
88
+ }
89
+ if (
90
+ input.timeoutSec !== undefined &&
91
+ input.timeoutSec !== null &&
92
+ (!Number.isInteger(input.timeoutSec) || input.timeoutSec <= 0 || input.timeoutSec > 86_400)
93
+ ) {
94
+ codexBadRequest('Hook timeout must be a positive number of seconds.');
95
+ }
96
+ }
97
+
98
+ function hooksPathForInput(codexHome: string, workspacePath: string, input: { scope: 'global' | 'project' }) {
99
+ return input.scope === 'global'
100
+ ? path.join(codexHome, 'hooks.json')
101
+ : path.join(workspacePath, '.codex', 'hooks.json');
102
+ }
103
+
104
+ function hookInputMatches(
105
+ group: unknown,
106
+ handler: unknown,
107
+ input: CreateThreadHookInput,
108
+ ) {
109
+ if (!isRecord(group) || !isRecord(handler)) {
110
+ return false;
111
+ }
112
+ const matcher = typeof group.matcher === 'string' ? group.matcher : null;
113
+ const handlerCommand = typeof handler.command === 'string' ? handler.command : '';
114
+ const handlerTimeout =
115
+ typeof handler.timeout === 'number' && Number.isFinite(handler.timeout)
116
+ ? handler.timeout
117
+ : null;
118
+ const handlerStatusMessage =
119
+ typeof handler.statusMessage === 'string' ? handler.statusMessage : null;
120
+ return (
121
+ handler.type === 'command' &&
122
+ (input.matcher?.trim() || null) === matcher &&
123
+ input.command.trim() === handlerCommand &&
124
+ (input.timeoutSec ?? null) === handlerTimeout &&
125
+ (input.statusMessage?.trim() || null) === handlerStatusMessage
126
+ );
127
+ }
128
+
129
+ function hookMatchesInput(hook: AgentHookDto, input: CreateThreadHookInput) {
130
+ return (
131
+ hook.source === input.scope &&
132
+ hook.eventName === input.eventName &&
133
+ (hook.matcher ?? null) === (input.matcher ?? null) &&
134
+ hook.command === input.command &&
135
+ (input.timeoutSec == null || hook.timeoutSec === input.timeoutSec) &&
136
+ (hook.statusMessage ?? null) === (input.statusMessage ?? null)
137
+ );
138
+ }
139
+
140
+ async function findOfficialHookForInput(
141
+ runtime: AgentRuntime,
142
+ workspacePath: string,
143
+ input: CreateThreadHookInput,
144
+ ): Promise<AgentHookDto | null> {
145
+ if (!runtime.listHooks) {
146
+ return null;
147
+ }
148
+ const [entry] = await runtime.listHooks({
149
+ cwds: [workspacePath],
150
+ }) as Awaited<ReturnType<NonNullable<AgentRuntime['listHooks']>>>;
151
+ const officialHooks: AgentHookDto[] = (entry?.hooks ?? []).map((hook) => ({
152
+ key: hook.key,
153
+ eventName: hook.eventName as AgentHookDto['eventName'],
154
+ handlerType: hook.handlerType as AgentHookDto['handlerType'],
155
+ matcher: hook.matcher,
156
+ command: hook.command,
157
+ timeoutSec: hook.timeoutSec,
158
+ statusMessage: hook.statusMessage,
159
+ sourcePath: hook.sourcePath,
160
+ source: hook.source as AgentHookDto['source'],
161
+ pluginId: hook.pluginId,
162
+ displayOrder: hook.displayOrder,
163
+ enabled: hook.enabled,
164
+ isManaged: hook.isManaged,
165
+ currentHash: hook.currentHash,
166
+ trustStatus: hook.trustStatus as AgentHookDto['trustStatus'],
167
+ }));
168
+ return officialHooks.find((hook) => hookMatchesInput(hook, input)) ?? null;
169
+ }
170
+
171
+ async function trustHookForInput(
172
+ runtime: AgentRuntime,
173
+ workspacePath: string,
174
+ input: CreateThreadHookInput,
175
+ ) {
176
+ const hook = await findOfficialHookForInput(runtime, workspacePath, input);
177
+ if (!runtime.setHookTrust || !hook || !hook.key || !hook.currentHash || hook.isManaged) {
178
+ return;
179
+ }
180
+
181
+ await runtime.setHookTrust({
182
+ key: hook.key,
183
+ trustedHash: hook.currentHash,
184
+ });
185
+ }
186
+
187
+ export class CodexManagementService {
188
+ constructor(private readonly codexHome: string) {}
189
+
190
+ readFastMode() {
191
+ return readCodexFastModeSync(this.codexHome);
192
+ }
193
+
194
+ writeFastMode(enabled: boolean) {
195
+ return writeCodexFastMode(this.codexHome, enabled);
196
+ }
197
+
198
+ mapGoalError(error: unknown): never {
199
+ const codexError = unwrapCodexJsonRpcError(error);
200
+ if (codexError) {
201
+ const remoteMessage = codexError.message || '';
202
+ if (remoteMessage.toLowerCase().includes('goals feature is disabled')) {
203
+ throw new CodexManagementError(409, {
204
+ code: 'goal_feature_disabled',
205
+ message: GOAL_FEATURE_DISABLED_MESSAGE,
206
+ });
207
+ }
208
+
209
+ throw new CodexManagementError(502, {
210
+ code: 'provider_goal_error',
211
+ message: remoteMessage || 'Provider goal operation failed.',
212
+ details: {
213
+ provider: 'codex',
214
+ },
215
+ });
216
+ }
217
+
218
+ throw error;
219
+ }
220
+
221
+ async ensureGoalsFeatureEnabled(runtime: AgentRuntime) {
222
+ try {
223
+ if (await readCodexFeatureFlag(this.codexHome, 'goals')) {
224
+ return;
225
+ }
226
+
227
+ await writeCodexFeatureFlag(this.codexHome, 'goals', true);
228
+ await runtime.stop();
229
+ await runtime.start();
230
+ } catch (error) {
231
+ if (isCodexRuntimeRequestError(error)) {
232
+ throw new CodexManagementError(409, {
233
+ code: 'goal_feature_disabled',
234
+ message: GOAL_FEATURE_DISABLED_MESSAGE,
235
+ });
236
+ }
237
+ throw error;
238
+ }
239
+ }
240
+
241
+ isRuntimeRequestError(error: unknown) {
242
+ return isCodexRuntimeRequestError(error);
243
+ }
244
+
245
+ canManageHookFiles(provider: string | null | undefined) {
246
+ return !provider || provider === 'codex';
247
+ }
248
+
249
+ isUnsupportedHooksListError(error: unknown) {
250
+ return isUnsupportedHooksListError(error);
251
+ }
252
+
253
+ hooksListFallbackWarning() {
254
+ return 'Codex app-server does not expose hooks/list yet; showing hooks parsed from hooks.json only.';
255
+ }
256
+
257
+ async writeHookEntry(
258
+ runtime: AgentRuntime,
259
+ workspacePath: string,
260
+ input: CreateThreadHookInput,
261
+ ) {
262
+ validateHookInput(input);
263
+
264
+ const hooksPath = hooksPathForInput(this.codexHome, workspacePath, input);
265
+ const config = await readJsonFileOrDefault(hooksPath);
266
+ const eventKey = HOOK_EVENT_JSON_KEYS[input.eventName];
267
+ const matcher = input.matcher?.trim() || null;
268
+ const handler: Record<string, unknown> = {
269
+ type: 'command',
270
+ command: input.command.trim(),
271
+ };
272
+ if (input.timeoutSec !== undefined && input.timeoutSec !== null) {
273
+ handler.timeout = input.timeoutSec;
274
+ }
275
+ if (input.statusMessage?.trim()) {
276
+ handler.statusMessage = input.statusMessage.trim();
277
+ }
278
+
279
+ const group: Record<string, unknown> = {
280
+ hooks: [handler],
281
+ };
282
+ if (matcher) {
283
+ group.matcher = matcher;
284
+ }
285
+
286
+ const currentGroups = Array.isArray(config.hooks[eventKey])
287
+ ? config.hooks[eventKey]
288
+ : [];
289
+ config.hooks[eventKey] = [...currentGroups, group];
290
+
291
+ await fs.mkdir(path.dirname(hooksPath), { recursive: true });
292
+ await fs.writeFile(hooksPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
293
+ await trustHookForInput(runtime, workspacePath, input);
294
+ }
295
+
296
+ async updateHookEntry(
297
+ runtime: AgentRuntime,
298
+ workspacePath: string,
299
+ input: UpdateThreadHookInput,
300
+ ) {
301
+ validateHookInput(input);
302
+ validateHookInput(input.target);
303
+
304
+ if (input.scope !== input.target.scope) {
305
+ codexBadRequest('Hook scope cannot be changed while editing.');
306
+ }
307
+
308
+ const hooksPath = hooksPathForInput(this.codexHome, workspacePath, input);
309
+ const config = await readJsonFileOrDefault(hooksPath);
310
+ const targetEventKey = HOOK_EVENT_JSON_KEYS[input.target.eventName];
311
+ const nextEventKey = HOOK_EVENT_JSON_KEYS[input.eventName];
312
+ const currentGroups = Array.isArray(config.hooks[targetEventKey])
313
+ ? config.hooks[targetEventKey]
314
+ : [];
315
+ let replacementGroup: Record<string, unknown> | null = null;
316
+
317
+ config.hooks[targetEventKey] = currentGroups
318
+ .map((group) => {
319
+ if (replacementGroup || !isRecord(group) || !Array.isArray(group.hooks)) {
320
+ return group;
321
+ }
322
+ const hookIndex = group.hooks.findIndex((handler) =>
323
+ hookInputMatches(group, handler, input.target),
324
+ );
325
+ if (hookIndex < 0) {
326
+ return group;
327
+ }
328
+
329
+ const handler: Record<string, unknown> = {
330
+ type: 'command',
331
+ command: input.command.trim(),
332
+ };
333
+ if (input.timeoutSec !== undefined && input.timeoutSec !== null) {
334
+ handler.timeout = input.timeoutSec;
335
+ }
336
+ if (input.statusMessage?.trim()) {
337
+ handler.statusMessage = input.statusMessage.trim();
338
+ }
339
+ replacementGroup = {
340
+ hooks: [handler],
341
+ };
342
+ const matcher = input.matcher?.trim() || null;
343
+ if (matcher) {
344
+ replacementGroup.matcher = matcher;
345
+ }
346
+
347
+ if (targetEventKey !== nextEventKey) {
348
+ const remainingHooks = group.hooks.filter((_, index) => index !== hookIndex);
349
+ return {
350
+ ...group,
351
+ hooks: remainingHooks,
352
+ };
353
+ }
354
+
355
+ return {
356
+ ...replacementGroup,
357
+ hooks: group.hooks.map((existing, index) =>
358
+ index === hookIndex
359
+ ? (replacementGroup!.hooks as unknown[])[0]
360
+ : existing,
361
+ ),
362
+ };
363
+ })
364
+ .filter((group) => {
365
+ if (!isRecord(group) || !Array.isArray(group.hooks)) {
366
+ return true;
367
+ }
368
+ return group.hooks.length > 0;
369
+ });
370
+
371
+ if (!replacementGroup) {
372
+ throw new CodexManagementError(404, {
373
+ code: 'not_found',
374
+ message: 'Hook was not found in hooks.json.',
375
+ });
376
+ }
377
+
378
+ if (targetEventKey !== nextEventKey) {
379
+ if (config.hooks[targetEventKey]?.length === 0) {
380
+ delete config.hooks[targetEventKey];
381
+ }
382
+ const nextGroups = Array.isArray(config.hooks[nextEventKey])
383
+ ? config.hooks[nextEventKey]
384
+ : [];
385
+ config.hooks[nextEventKey] = [...nextGroups, replacementGroup];
386
+ }
387
+
388
+ await fs.mkdir(path.dirname(hooksPath), { recursive: true });
389
+ await fs.writeFile(hooksPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
390
+ await trustHookForInput(runtime, workspacePath, input);
391
+ }
392
+
393
+ async readLocalHookDtos(input: {
394
+ hooksPath: string;
395
+ source: 'user' | 'project';
396
+ displayOffset: number;
397
+ }): Promise<AgentHookDto[]> {
398
+ const config = await readJsonFileOrDefault(input.hooksPath);
399
+ const hooks: AgentHookDto[] = [];
400
+ for (const [eventKey, groups] of Object.entries(config.hooks)) {
401
+ const eventName = HOOK_EVENT_DTO_KEYS[eventKey];
402
+ if (!eventName || !Array.isArray(groups)) {
403
+ continue;
404
+ }
405
+ groups.forEach((group, groupIndex) => {
406
+ if (!isRecord(group) || !Array.isArray(group.hooks)) {
407
+ return;
408
+ }
409
+ const matcher = typeof group.matcher === 'string' ? group.matcher : null;
410
+ group.hooks.forEach((handler, handlerIndex) => {
411
+ if (!isRecord(handler) || handler.type !== 'command') {
412
+ return;
413
+ }
414
+ const command = typeof handler.command === 'string' ? handler.command : null;
415
+ if (!command) {
416
+ return;
417
+ }
418
+ const timeoutSec =
419
+ typeof handler.timeout === 'number' && Number.isFinite(handler.timeout)
420
+ ? handler.timeout
421
+ : 600;
422
+ const statusMessage =
423
+ typeof handler.statusMessage === 'string' ? handler.statusMessage : null;
424
+ const key = `${input.source}:${input.hooksPath}:${eventKey}:${groupIndex}:${handlerIndex}`;
425
+ hooks.push({
426
+ key,
427
+ eventName,
428
+ handlerType: 'command',
429
+ matcher,
430
+ command,
431
+ timeoutSec,
432
+ statusMessage,
433
+ sourcePath: input.hooksPath,
434
+ source: input.source,
435
+ pluginId: null,
436
+ displayOrder: input.displayOffset + hooks.length,
437
+ enabled: true,
438
+ isManaged: false,
439
+ currentHash: '',
440
+ trustStatus: 'untrusted',
441
+ });
442
+ });
443
+ });
444
+ }
445
+ return hooks;
446
+ }
447
+
448
+ hooksPaths(workspacePath: string) {
449
+ return {
450
+ globalHooksPath: path.join(this.codexHome, 'hooks.json'),
451
+ projectHooksPath: path.join(workspacePath, '.codex', 'hooks.json'),
452
+ };
453
+ }
454
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ isCodexFeatureEnabledFromConfig,
5
+ upsertCodexFeatureFlag,
6
+ upsertCodexServiceTier,
7
+ } from './codexHostConfig';
8
+
9
+ describe('codexHostConfig', () => {
10
+ it('adds service_tier = "fast" when enabling fast mode', () => {
11
+ expect(upsertCodexServiceTier('model = "gpt-5.4"\n', true)).toBe(
12
+ 'model = "gpt-5.4"\nservice_tier = "fast"\n',
13
+ );
14
+ });
15
+
16
+ it('inserts service_tier before the first section when enabling fast mode', () => {
17
+ expect(
18
+ upsertCodexServiceTier(
19
+ 'model = "gpt-5.4"\n\n[projects."/tmp/example"]\ntrust_level = "trusted"\n',
20
+ true,
21
+ ),
22
+ ).toBe(
23
+ 'model = "gpt-5.4"\nservice_tier = "fast"\n[projects."/tmp/example"]\ntrust_level = "trusted"\n',
24
+ );
25
+ });
26
+
27
+ it('removes service_tier when disabling fast mode', () => {
28
+ expect(
29
+ upsertCodexServiceTier(
30
+ 'model = "gpt-5.4"\nservice_tier = "fast"\napproval_policy = "never"\n',
31
+ false,
32
+ ),
33
+ ).toBe('model = "gpt-5.4"\napproval_policy = "never"\n');
34
+ });
35
+
36
+ it('removes unsupported legacy flex values when disabling fast mode', () => {
37
+ expect(
38
+ upsertCodexServiceTier('service_tier = "flex"\nmodel = "gpt-5.4"\n', false),
39
+ ).toBe('model = "gpt-5.4"\n');
40
+ });
41
+
42
+ it('writes only the fast line when enabling into an empty config', () => {
43
+ expect(upsertCodexServiceTier('', true)).toBe('service_tier = "fast"\n');
44
+ });
45
+
46
+ it('clears the file when disabling and service_tier is the only entry', () => {
47
+ expect(upsertCodexServiceTier('service_tier = "fast"\n', false)).toBe('');
48
+ });
49
+
50
+ it('inserts feature flags without merging with the next section header', () => {
51
+ expect(
52
+ upsertCodexFeatureFlag(
53
+ [
54
+ 'model = "gpt-5.4"',
55
+ '',
56
+ '[features]',
57
+ 'multi_agent = true',
58
+ '[notice]',
59
+ 'hide_full_access_warning = true',
60
+ '',
61
+ ].join('\n'),
62
+ 'goals',
63
+ true,
64
+ ),
65
+ ).toBe(
66
+ [
67
+ 'model = "gpt-5.4"',
68
+ '',
69
+ '[features]',
70
+ 'multi_agent = true',
71
+ 'goals = true',
72
+ '',
73
+ '[notice]',
74
+ 'hide_full_access_warning = true',
75
+ '',
76
+ ].join('\n'),
77
+ );
78
+ });
79
+
80
+ it('reads feature flags only from the features section', () => {
81
+ expect(
82
+ isCodexFeatureEnabledFromConfig(
83
+ '[features]\ngoals = true\n\n[notice]\ngoals = false\n',
84
+ 'goals',
85
+ ),
86
+ ).toBe(true);
87
+ });
88
+ });