pi-openai-codex-compat 0.0.1-alpha.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 (38) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +20 -0
  3. package/LICENSES/Apache-2.0.txt +201 -0
  4. package/LICENSES/pi-ai-MIT.txt +21 -0
  5. package/README.md +331 -0
  6. package/THIRD_PARTY_NOTICES.md +21 -0
  7. package/extensions/openai-codex-compat/apply-patch-diff-render.ts +436 -0
  8. package/extensions/openai-codex-compat/apply-patch-engine.ts +1004 -0
  9. package/extensions/openai-codex-compat/apply-patch-render.ts +133 -0
  10. package/extensions/openai-codex-compat/apply-patch.ts +142 -0
  11. package/extensions/openai-codex-compat/codex-protocol.ts +598 -0
  12. package/extensions/openai-codex-compat/codex-provider.ts +740 -0
  13. package/extensions/openai-codex-compat/codex-stream.ts +444 -0
  14. package/extensions/openai-codex-compat/codex-tool-surface.ts +186 -0
  15. package/extensions/openai-codex-compat/codex-transport.ts +855 -0
  16. package/extensions/openai-codex-compat/compaction-checkpoint.ts +304 -0
  17. package/extensions/openai-codex-compat/config.ts +268 -0
  18. package/extensions/openai-codex-compat/footer.ts +99 -0
  19. package/extensions/openai-codex-compat/image-generation-render.ts +166 -0
  20. package/extensions/openai-codex-compat/image-generation.ts +355 -0
  21. package/extensions/openai-codex-compat/index.ts +65 -0
  22. package/extensions/openai-codex-compat/model-policy.ts +67 -0
  23. package/extensions/openai-codex-compat/namespaced-tools.ts +43 -0
  24. package/extensions/openai-codex-compat/native-history.ts +78 -0
  25. package/extensions/openai-codex-compat/remote-compaction.ts +198 -0
  26. package/extensions/openai-codex-compat/request-options.ts +121 -0
  27. package/extensions/openai-codex-compat/responses-replay.ts +33 -0
  28. package/extensions/openai-codex-compat/settings-pane.ts +298 -0
  29. package/extensions/openai-codex-compat/tool-runtime.ts +32 -0
  30. package/extensions/openai-codex-compat/tools.ts +70 -0
  31. package/extensions/openai-codex-compat/vendor/pi-ai/README.md +15 -0
  32. package/extensions/openai-codex-compat/vendor/pi-ai/openai-responses-serialization.ts +660 -0
  33. package/extensions/openai-codex-compat/web-run-description.txt +105 -0
  34. package/extensions/openai-codex-compat/web-run-output.ts +172 -0
  35. package/extensions/openai-codex-compat/web-run-render.ts +681 -0
  36. package/extensions/openai-codex-compat/web-run-schema.ts +301 -0
  37. package/extensions/openai-codex-compat/web-run.ts +164 -0
  38. package/package.json +63 -0
@@ -0,0 +1,304 @@
1
+ import {
2
+ buildSessionContext,
3
+ convertToLlm,
4
+ sessionEntryToContextMessages,
5
+ type SessionEntry,
6
+ type ToolInfo,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import type { Message, Model, OpenAIResponsesCompat, Tool } from "@earendil-works/pi-ai";
9
+ import { APPLY_PATCH_LARK_GRAMMAR, APPLY_PATCH_TOOL_NAME } from "./apply-patch.ts";
10
+ import type { ImageDetail } from "./config.ts";
11
+ import {
12
+ installCompactionItem,
13
+ isObject,
14
+ isResponsesItem,
15
+ type ResponsesItem,
16
+ } from "./codex-protocol.ts";
17
+ import { nativeResponseOverrides } from "./native-history.ts";
18
+ import {
19
+ CODEX_NAMESPACED_TOOL_NAMES,
20
+ CODEX_TEXT_CONTENT_ITEM_TOOL_RESULT_NAMES,
21
+ splitNamespacedToolName,
22
+ } from "./namespaced-tools.ts";
23
+ import { convertResponsesMessages } from "./vendor/pi-ai/openai-responses-serialization.ts";
24
+
25
+ export const CHECKPOINT_ENTRY_TYPE = "openai-codex-compat-remote-compaction";
26
+ export const CHECKPOINT_FORMAT_VERSION = 1;
27
+
28
+ export type CheckpointData = {
29
+ kind: typeof CHECKPOINT_ENTRY_TYPE;
30
+ version: typeof CHECKPOINT_FORMAT_VERSION;
31
+ modelId: string;
32
+ history: ResponsesItem[];
33
+ };
34
+
35
+ export type CheckpointSearch =
36
+ | { kind: "absent" }
37
+ | { kind: "corrupt"; entryIndex: number; entryId: string }
38
+ | { kind: "found"; entryIndex: number; entryId: string; data: CheckpointData };
39
+
40
+ export type GrammarToolInputProperties = ReadonlyMap<string, string>;
41
+
42
+ function asResponsesTool(
43
+ tool: ToolInfo,
44
+ grammarToolInputProperties: GrammarToolInputProperties,
45
+ ): ResponsesItem {
46
+ if (tool.name === APPLY_PATCH_TOOL_NAME && grammarToolInputProperties.has(tool.name)) {
47
+ return {
48
+ type: "custom",
49
+ name: tool.name,
50
+ description: tool.description,
51
+ format: {
52
+ type: "grammar",
53
+ syntax: "lark",
54
+ definition: APPLY_PATCH_LARK_GRAMMAR,
55
+ },
56
+ };
57
+ }
58
+ const namespaced = splitNamespacedToolName(tool.name);
59
+ if (namespaced) {
60
+ return {
61
+ type: "namespace",
62
+ name: namespaced.namespace,
63
+ description: `Tools in the ${namespaced.namespace} namespace.`,
64
+ tools: [
65
+ {
66
+ type: "function",
67
+ name: namespaced.name,
68
+ description: tool.description,
69
+ parameters: tool.parameters as unknown,
70
+ strict: false,
71
+ },
72
+ ],
73
+ };
74
+ }
75
+ return {
76
+ type: "function",
77
+ name: tool.name,
78
+ description: tool.description,
79
+ parameters: tool.parameters as unknown,
80
+ strict: null,
81
+ };
82
+ }
83
+
84
+ export function activeResponsesTools(
85
+ allTools: readonly ToolInfo[],
86
+ activeNames: readonly string[],
87
+ grammarToolInputProperties: GrammarToolInputProperties = new Map(),
88
+ ): unknown[] | undefined {
89
+ const enabled = new Set(activeNames);
90
+ const tools = allTools.filter((tool) => enabled.has(tool.name));
91
+ return tools.length > 0
92
+ ? tools.map((tool) => asResponsesTool(tool, grammarToolInputProperties))
93
+ : undefined;
94
+ }
95
+
96
+ const CODEX_TOOL_CALL_PROVIDERS: ReadonlySet<string> = new Set([
97
+ "openai",
98
+ "openai-codex",
99
+ "opencode",
100
+ ]);
101
+
102
+ function asPiTool(tool: ToolInfo, grammarToolInputProperties: GrammarToolInputProperties): Tool {
103
+ return {
104
+ name: tool.name,
105
+ description: tool.description,
106
+ parameters: tool.parameters,
107
+ ...(tool.name === APPLY_PATCH_TOOL_NAME && grammarToolInputProperties.has(tool.name)
108
+ ? {
109
+ constrainedSampling: {
110
+ type: "grammar" as const,
111
+ variants: { openai_lark: APPLY_PATCH_LARK_GRAMMAR },
112
+ },
113
+ }
114
+ : {}),
115
+ };
116
+ }
117
+
118
+ /** Encode Pi's canonical messages using Pi AI's OpenAI Responses serializer. */
119
+ function encodeMessages(
120
+ model: Model<any>,
121
+ messages: Message[],
122
+ allTools: readonly ToolInfo[],
123
+ grammarToolInputProperties: GrammarToolInputProperties,
124
+ imageDetail: ImageDetail,
125
+ nativeAssistantItems?: ReadonlyMap<string, readonly ResponsesItem[]>,
126
+ ): ResponsesItem[] {
127
+ const tools = allTools.map((tool) => asPiTool(tool, grammarToolInputProperties));
128
+ const compat = model.compat as OpenAIResponsesCompat | undefined;
129
+ return convertResponsesMessages(model, { messages, tools }, CODEX_TOOL_CALL_PROVIDERS, {
130
+ includeSystemPrompt: false,
131
+ grammarToolInputProperties,
132
+ deferredTools: new Map(tools.map((tool) => [tool.name, tool])),
133
+ toolOptions: {
134
+ strict: null,
135
+ supportsStrictMode: compat?.supportsStrictMode ?? true,
136
+ supportsOpenAIGrammarTools: compat?.supportsOpenAIGrammarTools ?? false,
137
+ },
138
+ namespacedToolNames: CODEX_NAMESPACED_TOOL_NAMES,
139
+ textContentItemToolResultNames: CODEX_TEXT_CONTENT_ITEM_TOOL_RESULT_NAMES,
140
+ toolResultImageDetail: imageDetail,
141
+ ...(nativeAssistantItems ? { nativeAssistantItems } : {}),
142
+ }) as unknown as ResponsesItem[];
143
+ }
144
+
145
+ export function encodeSessionEntries(
146
+ model: Model<any>,
147
+ entries: readonly SessionEntry[],
148
+ allTools: readonly ToolInfo[],
149
+ grammarToolInputProperties: GrammarToolInputProperties = new Map(),
150
+ imageDetail: ImageDetail = "auto",
151
+ nativeAssistantItems?: ReadonlyMap<string, readonly ResponsesItem[]>,
152
+ ): ResponsesItem[] {
153
+ const messages = entries.flatMap((entry) => sessionEntryToContextMessages(entry));
154
+ return encodeMessages(
155
+ model,
156
+ convertToLlm(messages),
157
+ allTools,
158
+ grammarToolInputProperties,
159
+ imageDetail,
160
+ nativeAssistantItems,
161
+ );
162
+ }
163
+
164
+ export function parseCheckpoint(value: unknown): CheckpointData | undefined {
165
+ if (!isObject(value)) return undefined;
166
+ if (value.kind !== CHECKPOINT_ENTRY_TYPE || value.version !== CHECKPOINT_FORMAT_VERSION) {
167
+ return undefined;
168
+ }
169
+ if (typeof value.modelId !== "string" || !Array.isArray(value.history)) return undefined;
170
+
171
+ const history: ResponsesItem[] = [];
172
+ for (const item of value.history) {
173
+ if (!isResponsesItem(item)) return undefined;
174
+ history.push(structuredClone(item));
175
+ }
176
+ if (history.length === 0) return undefined;
177
+
178
+ const compactionItems = history.filter((item) => item.type === "compaction");
179
+ if (compactionItems.length !== 1 || typeof compactionItems[0]!.encrypted_content !== "string") {
180
+ return undefined;
181
+ }
182
+
183
+ return {
184
+ kind: CHECKPOINT_ENTRY_TYPE,
185
+ version: CHECKPOINT_FORMAT_VERSION,
186
+ modelId: value.modelId,
187
+ history,
188
+ };
189
+ }
190
+
191
+ /** Find the newest applicable checkpoint on the active branch. */
192
+ export function searchCheckpoint(branch: readonly SessionEntry[]): CheckpointSearch {
193
+ for (let index = branch.length - 1; index >= 0; index--) {
194
+ const entry = branch[index]!;
195
+ let candidate: unknown;
196
+
197
+ if (entry.type === "compaction") {
198
+ if (!isObject(entry.details) || entry.details.kind !== CHECKPOINT_ENTRY_TYPE) {
199
+ return { kind: "absent" };
200
+ }
201
+ candidate = entry.details;
202
+ } else if (entry.type === "custom" && entry.customType === CHECKPOINT_ENTRY_TYPE) {
203
+ candidate = entry.data;
204
+ } else {
205
+ continue;
206
+ }
207
+
208
+ const data = parseCheckpoint(candidate);
209
+ if (!data) return { kind: "corrupt", entryIndex: index, entryId: entry.id };
210
+ return { kind: "found", entryIndex: index, entryId: entry.id, data };
211
+ }
212
+
213
+ return { kind: "absent" };
214
+ }
215
+
216
+ /** Return whether the active branch contains any native Codex checkpoint entry. */
217
+ export function hasNativeCheckpointEntry(branch: readonly SessionEntry[]): boolean {
218
+ return branch.some(
219
+ (entry) =>
220
+ (entry.type === "compaction" &&
221
+ isObject(entry.details) &&
222
+ entry.details.kind === CHECKPOINT_ENTRY_TYPE) ||
223
+ (entry.type === "custom" && entry.customType === CHECKPOINT_ENTRY_TYPE),
224
+ );
225
+ }
226
+
227
+ export function checkpointData(
228
+ modelId: string,
229
+ inputHistory: readonly ResponsesItem[],
230
+ compactionItem: ResponsesItem,
231
+ postCompactionTail: readonly ResponsesItem[] = [],
232
+ ): CheckpointData {
233
+ return {
234
+ kind: CHECKPOINT_ENTRY_TYPE,
235
+ version: CHECKPOINT_FORMAT_VERSION,
236
+ modelId,
237
+ history: [
238
+ ...installCompactionItem(inputHistory, compactionItem),
239
+ ...postCompactionTail.map((item) => structuredClone(item)),
240
+ ],
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Materialize provider history for the active branch. Checkpoint history
246
+ * replaces everything before its entry; later branch entries form the tail.
247
+ */
248
+ export function providerHistory(options: {
249
+ branch: readonly SessionEntry[];
250
+ wireModel: Model<any>;
251
+ allTools: readonly ToolInfo[];
252
+ grammarToolInputProperties?: GrammarToolInputProperties;
253
+ imageDetail?: ImageDetail;
254
+ dropLatestFailedAssistant?: boolean;
255
+ }): ResponsesItem[] {
256
+ const branch = [...options.branch];
257
+ if (options.dropLatestFailedAssistant) {
258
+ const index = branch.findLastIndex(
259
+ (entry) => entry.type === "message" && entry.message.role === "assistant",
260
+ );
261
+ const entry = index >= 0 ? branch[index] : undefined;
262
+ if (
263
+ entry?.type === "message" &&
264
+ entry.message.role === "assistant" &&
265
+ (entry.message.stopReason === "error" || entry.message.stopReason === "aborted")
266
+ ) {
267
+ branch.splice(index, 1);
268
+ }
269
+ }
270
+
271
+ const checkpoint = searchCheckpoint(branch);
272
+ const nativeAssistantItems = nativeResponseOverrides(branch, options.wireModel.id);
273
+ if (checkpoint.kind === "corrupt") {
274
+ throw new Error("The latest Codex compaction checkpoint is corrupt.");
275
+ }
276
+ if (checkpoint.kind === "found") {
277
+ if (checkpoint.data.modelId !== options.wireModel.id) {
278
+ throw new Error(
279
+ `The latest Codex compaction checkpoint belongs to ${checkpoint.data.modelId}, not ${options.wireModel.id}.`,
280
+ );
281
+ }
282
+ return [
283
+ ...checkpoint.data.history.map((item) => structuredClone(item)),
284
+ ...encodeSessionEntries(
285
+ options.wireModel,
286
+ branch.slice(checkpoint.entryIndex + 1),
287
+ options.allTools,
288
+ options.grammarToolInputProperties,
289
+ options.imageDetail,
290
+ nativeAssistantItems,
291
+ ),
292
+ ];
293
+ }
294
+
295
+ const context = buildSessionContext(branch);
296
+ return encodeMessages(
297
+ options.wireModel,
298
+ convertToLlm(context.messages),
299
+ options.allTools,
300
+ options.grammarToolInputProperties ?? new Map(),
301
+ options.imageDetail ?? "auto",
302
+ nativeAssistantItems,
303
+ );
304
+ }
@@ -0,0 +1,268 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
6
+
7
+ export type WebSearchMode = "disabled" | "cached" | "indexed" | "live";
8
+ export type TextVerbosity = "low" | "medium" | "high";
9
+ export type ReasoningSummary = "auto" | "concise" | "detailed" | "off";
10
+ export type ReasoningMode = "standard" | "pro";
11
+ export type ImageDetail = "auto" | "low" | "high" | "original";
12
+ export type CodexToolBackground = "subtle" | "status" | "none";
13
+
14
+ export interface CodexCompatConfig {
15
+ /** Send OpenAI Codex requests through the priority service tier. */
16
+ fastMode: boolean;
17
+ /** Replace Pi's active edit and write tools with the extension's apply_patch tool. */
18
+ applyPatch: boolean;
19
+ /** Select the shared background surface for extension-owned Codex tools. */
20
+ toolBackground: CodexToolBackground;
21
+ /** Expose the standalone Codex image-generation namespace tool. */
22
+ imageGeneration: boolean;
23
+ /** Set input_image.detail when image tool results are returned to the model. */
24
+ imageDetail: ImageDetail;
25
+ /** Expose the standalone Codex web-search namespace tool. */
26
+ webRun: boolean;
27
+ /**
28
+ * Compact at a provider request boundary when context usage reaches this
29
+ * percentage. Omit it to rely only on Pi's compaction lifecycle (`/compact`,
30
+ * threshold compaction, and overflow recovery).
31
+ */
32
+ autoCompactAtPercent?: number;
33
+ webSearch: WebSearchMode;
34
+ textVerbosity: TextVerbosity;
35
+ reasoningSummary: ReasoningSummary;
36
+ reasoningMode: ReasoningMode;
37
+ }
38
+
39
+ export type ConfigLayer = {
40
+ fastMode?: boolean;
41
+ applyPatch?: boolean;
42
+ toolBackground?: CodexToolBackground;
43
+ imageGeneration?: boolean;
44
+ imageDetail?: ImageDetail;
45
+ webRun?: boolean;
46
+ autoCompactAtPercent?: number | null;
47
+ webSearch?: WebSearchMode;
48
+ textVerbosity?: TextVerbosity;
49
+ reasoningSummary?: ReasoningSummary;
50
+ reasoningMode?: ReasoningMode;
51
+ };
52
+
53
+ export const CONFIG_FILE = "openai-codex-compat.json";
54
+ export const DEFAULT_CONFIG: CodexCompatConfig = {
55
+ fastMode: false,
56
+ applyPatch: true,
57
+ toolBackground: "subtle",
58
+ imageGeneration: true,
59
+ imageDetail: "auto",
60
+ webRun: true,
61
+ webSearch: "cached",
62
+ textVerbosity: "low",
63
+ reasoningSummary: "auto",
64
+ reasoningMode: "standard",
65
+ };
66
+
67
+ const WEB_SEARCH_MODES = new Set<WebSearchMode>(["disabled", "cached", "indexed", "live"]);
68
+ const TEXT_VERBOSITIES = new Set<TextVerbosity>(["low", "medium", "high"]);
69
+ const REASONING_SUMMARIES = new Set<ReasoningSummary>(["auto", "concise", "detailed", "off"]);
70
+ const REASONING_MODES = new Set<ReasoningMode>(["standard", "pro"]);
71
+ const IMAGE_DETAILS = new Set<ImageDetail>(["auto", "low", "high", "original"]);
72
+ const CODEX_TOOL_BACKGROUNDS = new Set<CodexToolBackground>(["subtle", "status", "none"]);
73
+
74
+ function isRecord(value: unknown): value is Record<string, unknown> {
75
+ return value !== null && typeof value === "object" && !Array.isArray(value);
76
+ }
77
+
78
+ export function parseConfig(value: unknown): ConfigLayer {
79
+ if (!isRecord(value)) return {};
80
+
81
+ const layer: ConfigLayer = {};
82
+
83
+ const fastMode = value["fastMode"];
84
+ if (typeof fastMode === "boolean") layer.fastMode = fastMode;
85
+
86
+ const applyPatch = value["applyPatch"];
87
+ if (typeof applyPatch === "boolean") layer.applyPatch = applyPatch;
88
+
89
+ const toolBackground = value["toolBackground"];
90
+ if (
91
+ typeof toolBackground === "string" &&
92
+ CODEX_TOOL_BACKGROUNDS.has(toolBackground as CodexToolBackground)
93
+ ) {
94
+ layer.toolBackground = toolBackground as CodexToolBackground;
95
+ }
96
+
97
+ const imageGeneration = value["imageGeneration"];
98
+ if (typeof imageGeneration === "boolean") layer.imageGeneration = imageGeneration;
99
+
100
+ const imageDetail = value["imageDetail"];
101
+ if (typeof imageDetail === "string" && IMAGE_DETAILS.has(imageDetail as ImageDetail)) {
102
+ layer.imageDetail = imageDetail as ImageDetail;
103
+ }
104
+
105
+ const webRun = value["webRun"];
106
+ if (typeof webRun === "boolean") layer.webRun = webRun;
107
+
108
+ const threshold = value["autoCompactAtPercent"];
109
+ if (threshold === null) {
110
+ layer.autoCompactAtPercent = null;
111
+ } else if (
112
+ typeof threshold === "number" &&
113
+ Number.isFinite(threshold) &&
114
+ threshold > 0 &&
115
+ threshold <= 100
116
+ ) {
117
+ layer.autoCompactAtPercent = threshold;
118
+ }
119
+
120
+ const webSearch = value["webSearch"];
121
+ if (typeof webSearch === "string" && WEB_SEARCH_MODES.has(webSearch as WebSearchMode)) {
122
+ layer.webSearch = webSearch as WebSearchMode;
123
+ }
124
+
125
+ const textVerbosity = value["textVerbosity"];
126
+ if (typeof textVerbosity === "string" && TEXT_VERBOSITIES.has(textVerbosity as TextVerbosity)) {
127
+ layer.textVerbosity = textVerbosity as TextVerbosity;
128
+ }
129
+
130
+ const reasoningSummary = value["reasoningSummary"];
131
+ if (
132
+ typeof reasoningSummary === "string" &&
133
+ REASONING_SUMMARIES.has(reasoningSummary as ReasoningSummary)
134
+ ) {
135
+ layer.reasoningSummary = reasoningSummary as ReasoningSummary;
136
+ }
137
+
138
+ const reasoningMode = value["reasoningMode"];
139
+ if (typeof reasoningMode === "string" && REASONING_MODES.has(reasoningMode as ReasoningMode)) {
140
+ layer.reasoningMode = reasoningMode as ReasoningMode;
141
+ }
142
+
143
+ return layer;
144
+ }
145
+
146
+ function readConfig(filePath: string): ConfigLayer {
147
+ if (!existsSync(filePath)) return {};
148
+
149
+ try {
150
+ return parseConfig(JSON.parse(readFileSync(filePath, "utf8")));
151
+ } catch {
152
+ // Invalid configuration must not prevent Pi from starting.
153
+ return {};
154
+ }
155
+ }
156
+
157
+ export function resolveConfig(
158
+ globalConfig: ConfigLayer,
159
+ projectConfig: ConfigLayer,
160
+ ): CodexCompatConfig {
161
+ const merged = { ...globalConfig, ...projectConfig };
162
+ return {
163
+ ...DEFAULT_CONFIG,
164
+ ...(typeof merged.fastMode === "boolean" ? { fastMode: merged.fastMode } : {}),
165
+ ...(typeof merged.applyPatch === "boolean" ? { applyPatch: merged.applyPatch } : {}),
166
+ ...(merged.toolBackground ? { toolBackground: merged.toolBackground } : {}),
167
+ ...(typeof merged.imageGeneration === "boolean"
168
+ ? { imageGeneration: merged.imageGeneration }
169
+ : {}),
170
+ ...(merged.imageDetail ? { imageDetail: merged.imageDetail } : {}),
171
+ ...(typeof merged.webRun === "boolean" ? { webRun: merged.webRun } : {}),
172
+ ...(typeof merged.autoCompactAtPercent === "number"
173
+ ? { autoCompactAtPercent: merged.autoCompactAtPercent }
174
+ : {}),
175
+ ...(merged.webSearch ? { webSearch: merged.webSearch } : {}),
176
+ ...(merged.textVerbosity ? { textVerbosity: merged.textVerbosity } : {}),
177
+ ...(merged.reasoningSummary ? { reasoningSummary: merged.reasoningSummary } : {}),
178
+ ...(merged.reasoningMode ? { reasoningMode: merged.reasoningMode } : {}),
179
+ };
180
+ }
181
+
182
+ export function globalConfigPath(): string {
183
+ return join(getAgentDir(), CONFIG_FILE);
184
+ }
185
+
186
+ export function projectConfigPath(cwd: string): string {
187
+ return join(cwd, CONFIG_DIR_NAME, CONFIG_FILE);
188
+ }
189
+
190
+ /** Use an existing trusted project override; otherwise persist global settings. */
191
+ export function writableConfigPath(cwd: string, projectTrusted: boolean): string {
192
+ const projectPath = projectConfigPath(cwd);
193
+ return projectTrusted && existsSync(projectPath) ? projectPath : globalConfigPath();
194
+ }
195
+
196
+ export function loadConfig(cwd: string, projectTrusted: boolean): CodexCompatConfig {
197
+ const globalConfig = readConfig(globalConfigPath());
198
+ const projectConfig = projectTrusted ? readConfig(projectConfigPath(cwd)) : {};
199
+ return resolveConfig(globalConfig, projectConfig);
200
+ }
201
+
202
+ export function configLayer(config: CodexCompatConfig): ConfigLayer {
203
+ return {
204
+ fastMode: config.fastMode,
205
+ applyPatch: config.applyPatch,
206
+ toolBackground: config.toolBackground,
207
+ imageGeneration: config.imageGeneration,
208
+ imageDetail: config.imageDetail,
209
+ webRun: config.webRun,
210
+ autoCompactAtPercent: config.autoCompactAtPercent ?? null,
211
+ webSearch: config.webSearch,
212
+ textVerbosity: config.textVerbosity,
213
+ reasoningSummary: config.reasoningSummary,
214
+ reasoningMode: config.reasoningMode,
215
+ };
216
+ }
217
+
218
+ async function readWritableConfig(filePath: string): Promise<Record<string, unknown>> {
219
+ try {
220
+ const value = JSON.parse(await readFile(filePath, "utf8")) as unknown;
221
+ if (!isRecord(value)) throw new Error("the root value must be a JSON object");
222
+ return value;
223
+ } catch (error) {
224
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
225
+ const detail = error instanceof Error ? error.message : String(error);
226
+ throw new Error(`Cannot update ${filePath}: ${detail}`);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Merge known setting changes into the dedicated extension file. Unknown keys
232
+ * are retained, and an invalid existing file is never overwritten.
233
+ */
234
+ export async function saveConfig(
235
+ cwd: string,
236
+ projectTrusted: boolean,
237
+ patch: ConfigLayer,
238
+ ): Promise<string> {
239
+ const filePath = writableConfigPath(cwd, projectTrusted);
240
+ const current = await readWritableConfig(filePath);
241
+ const next = { ...current, ...patch };
242
+ const directory = dirname(filePath);
243
+ const temporaryPath = join(
244
+ directory,
245
+ `.${basename(filePath)}.${process.pid}.${randomUUID()}.tmp`,
246
+ );
247
+
248
+ await mkdir(directory, { recursive: true });
249
+ const existingMode = await stat(filePath)
250
+ .then((metadata) => metadata.mode & 0o777)
251
+ .catch((error: NodeJS.ErrnoException) => {
252
+ if (error.code === "ENOENT") return 0o600;
253
+ throw error;
254
+ });
255
+
256
+ try {
257
+ await writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, {
258
+ encoding: "utf8",
259
+ flag: "wx",
260
+ mode: existingMode,
261
+ });
262
+ await rename(temporaryPath, filePath);
263
+ } finally {
264
+ await rm(temporaryPath, { force: true });
265
+ }
266
+
267
+ return filePath;
268
+ }
@@ -0,0 +1,99 @@
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+ import {
3
+ FooterComponent,
4
+ type ExtensionContext,
5
+ type ReadonlyFooterDataProvider,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import type { Component, TUI } from "@earendil-works/pi-tui";
8
+ import type { CodexCompatConfig } from "./config.ts";
9
+ import { isCodexModel } from "./request-options.ts";
10
+
11
+ export type ConfigResolver = (ctx: ExtensionContext) => CodexCompatConfig;
12
+
13
+ export function footerSettingLabels(config: CodexCompatConfig): string[] {
14
+ return [
15
+ config.fastMode ? "fast" : undefined,
16
+ config.reasoningMode === "pro" ? "pro" : undefined,
17
+ config.textVerbosity !== "low" ? `verbosity ${config.textVerbosity}` : undefined,
18
+ config.reasoningSummary !== "auto" ? `summary ${config.reasoningSummary}` : undefined,
19
+ ].filter((label): label is string => label !== undefined);
20
+ }
21
+
22
+ export function footerModel(
23
+ model: Model<any> | undefined,
24
+ thinkingLevel: string,
25
+ config: CodexCompatConfig,
26
+ ): Model<any> | undefined {
27
+ if (!model || !isCodexModel(model)) return model;
28
+
29
+ const settings = footerSettingLabels(config);
30
+ if (settings.length === 0) return model;
31
+
32
+ const reasoning = model.reasoning
33
+ ? thinkingLevel === "off"
34
+ ? "thinking off"
35
+ : thinkingLevel
36
+ : undefined;
37
+ const id = [model.id, reasoning, ...settings]
38
+ .filter((part): part is string => part !== undefined)
39
+ .join(" • ");
40
+ return { ...model, id, reasoning: false };
41
+ }
42
+
43
+ type FooterSession = ConstructorParameters<typeof FooterComponent>[0];
44
+
45
+ function footerSession(ctx: ExtensionContext, resolveConfig: ConfigResolver): FooterSession {
46
+ return {
47
+ get state() {
48
+ return {
49
+ model: footerModel(ctx.model, ctx.thinkingLevel ?? "off", resolveConfig(ctx)),
50
+ thinkingLevel: ctx.thinkingLevel ?? "off",
51
+ };
52
+ },
53
+ sessionManager: ctx.sessionManager,
54
+ getContextUsage: () => ctx.getContextUsage(),
55
+ modelRuntime: {
56
+ isUsingOAuth(provider: string) {
57
+ const model = ctx.model;
58
+ return Boolean(
59
+ model && model.provider === provider && ctx.modelRegistry.isUsingOAuth(model),
60
+ );
61
+ },
62
+ },
63
+ } as unknown as FooterSession;
64
+ }
65
+
66
+ class CodexFooter implements Component {
67
+ private readonly footer: FooterComponent;
68
+ private readonly unsubscribe: () => void;
69
+
70
+ constructor(
71
+ tui: TUI,
72
+ footerData: ReadonlyFooterDataProvider,
73
+ ctx: ExtensionContext,
74
+ resolveConfig: ConfigResolver,
75
+ ) {
76
+ this.footer = new FooterComponent(footerSession(ctx, resolveConfig), footerData);
77
+ this.unsubscribe = footerData.onBranchChange(() => tui.requestRender());
78
+ }
79
+
80
+ render(width: number): string[] {
81
+ return this.footer.render(width);
82
+ }
83
+
84
+ invalidate(): void {
85
+ this.footer.invalidate();
86
+ }
87
+
88
+ dispose(): void {
89
+ this.unsubscribe();
90
+ this.footer.dispose();
91
+ }
92
+ }
93
+
94
+ export function installCodexFooter(ctx: ExtensionContext, resolveConfig: ConfigResolver): void {
95
+ if (ctx.mode !== "tui") return;
96
+ ctx.ui.setFooter(
97
+ (tui, _theme, footerData) => new CodexFooter(tui, footerData, ctx, resolveConfig),
98
+ );
99
+ }