pi-openai-codex-compat 0.0.16 → 0.0.17
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.
- package/CHANGELOG.md +23 -0
- package/README.md +57 -4
- package/extensions/openai-codex-compat/command-tools.ts +2 -0
- package/extensions/openai-codex-compat/config.ts +2 -56
- package/extensions/openai-codex-compat/index.ts +9 -3
- package/extensions/openai-codex-compat/settings-pane.ts +227 -536
- package/extensions/settings-menu.ts +618 -0
- package/extensions/settings-session.ts +130 -0
- package/extensions/settings-store.ts +240 -0
- package/package.json +1 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { SettingsField } from "./settings-menu.ts";
|
|
3
|
+
import {
|
|
4
|
+
observedSetting,
|
|
5
|
+
type SettingsSessionState,
|
|
6
|
+
type SettingValue,
|
|
7
|
+
type SettingValues,
|
|
8
|
+
} from "./settings-store.ts";
|
|
9
|
+
|
|
10
|
+
export type SettingsSessionContext = {
|
|
11
|
+
sessionManager: Pick<ExtensionContext["sessionManager"], "getSessionId" | "getEntries">;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function object(value: unknown): value is Record<string, unknown> {
|
|
15
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function valid(field: SettingsField, value: unknown): value is SettingValue {
|
|
19
|
+
if (field.choices.some((choice) => choice === value)) return true;
|
|
20
|
+
const rule = field.number;
|
|
21
|
+
return Boolean(
|
|
22
|
+
rule &&
|
|
23
|
+
typeof value === "number" &&
|
|
24
|
+
Number.isFinite(value) &&
|
|
25
|
+
(!rule.integer || Number.isInteger(value)) &&
|
|
26
|
+
(rule.exclusiveMin ? value > rule.min : value >= rule.min) &&
|
|
27
|
+
value <= rule.max,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Session-wide preferences deliberately ignore tree position and copied fork entries. */
|
|
32
|
+
export function readSessionSettings(
|
|
33
|
+
ctx: SettingsSessionContext,
|
|
34
|
+
customType: string,
|
|
35
|
+
fields: SettingsField[],
|
|
36
|
+
): { values: SettingValues; session: SettingsSessionState } {
|
|
37
|
+
const entries = ctx.sessionManager.getEntries();
|
|
38
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
39
|
+
const entry = entries[index];
|
|
40
|
+
if (entry?.type !== "custom" || entry.customType !== customType) continue;
|
|
41
|
+
const data: unknown = entry.data;
|
|
42
|
+
if (
|
|
43
|
+
object(data) &&
|
|
44
|
+
typeof data["sessionId"] === "string" &&
|
|
45
|
+
data["sessionId"] !== ctx.sessionManager.getSessionId()
|
|
46
|
+
)
|
|
47
|
+
continue;
|
|
48
|
+
const fail = () => {
|
|
49
|
+
throw new Error(`Invalid saved session settings (${customType}).`);
|
|
50
|
+
};
|
|
51
|
+
if (
|
|
52
|
+
!object(data) ||
|
|
53
|
+
data["sessionId"] !== ctx.sessionManager.getSessionId() ||
|
|
54
|
+
data["version"] !== 1 ||
|
|
55
|
+
!object(data["values"]) ||
|
|
56
|
+
!object(data["changes"]) ||
|
|
57
|
+
!object(data["observed"])
|
|
58
|
+
)
|
|
59
|
+
return fail();
|
|
60
|
+
const values: SettingValues = {};
|
|
61
|
+
const session: SettingsSessionState = { changes: {} };
|
|
62
|
+
for (const [id, value] of Object.entries(data["values"])) {
|
|
63
|
+
const field = fields.find((candidate) => candidate.id === id);
|
|
64
|
+
if (!field || !valid(field, value)) return fail();
|
|
65
|
+
values[id] = value;
|
|
66
|
+
}
|
|
67
|
+
const observed: Record<string, [string, string]> = {};
|
|
68
|
+
for (const [id, change] of Object.entries(data["changes"])) {
|
|
69
|
+
const field = fields.find((candidate) => candidate.id === id);
|
|
70
|
+
if (!field || !Array.isArray(change) || change.length > 1 || !Object.hasOwn(values, id))
|
|
71
|
+
return fail();
|
|
72
|
+
const value: unknown = change[0];
|
|
73
|
+
if (change.length === 0) session.changes[id] = undefined;
|
|
74
|
+
else if (valid(field, value)) session.changes[id] = value;
|
|
75
|
+
else return fail();
|
|
76
|
+
const hashes = data["observed"][id];
|
|
77
|
+
if (!Array.isArray(hashes) || hashes.length !== 2) return fail();
|
|
78
|
+
const global: unknown = hashes[0];
|
|
79
|
+
const project: unknown = hashes[1];
|
|
80
|
+
if (
|
|
81
|
+
typeof global !== "string" ||
|
|
82
|
+
typeof project !== "string" ||
|
|
83
|
+
!/^[a-f0-9]{64}$/.test(global) ||
|
|
84
|
+
!/^[a-f0-9]{64}$/.test(project)
|
|
85
|
+
)
|
|
86
|
+
return fail();
|
|
87
|
+
observed[id] = [global, project];
|
|
88
|
+
}
|
|
89
|
+
if (Object.keys(session.changes).length) {
|
|
90
|
+
if (typeof data["file"] !== "string") return fail();
|
|
91
|
+
session.baseline = {
|
|
92
|
+
file: data["file"],
|
|
93
|
+
observed,
|
|
94
|
+
values: {},
|
|
95
|
+
inherited: {},
|
|
96
|
+
sources: {},
|
|
97
|
+
global: { text: undefined, data: {} },
|
|
98
|
+
project: { text: undefined, data: {} },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return { values, session };
|
|
102
|
+
}
|
|
103
|
+
return { values: {}, session: { changes: {} } };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Store only known preferences and conflict hashes, never raw configuration documents. */
|
|
107
|
+
export function sessionSettingsEntry(
|
|
108
|
+
sessionId: string,
|
|
109
|
+
values: SettingValues,
|
|
110
|
+
session: SettingsSessionState,
|
|
111
|
+
locked: Record<string, string>,
|
|
112
|
+
) {
|
|
113
|
+
const writable = Object.entries(values).filter(([id]) => !locked[id]);
|
|
114
|
+
const changes = Object.entries(session.changes).filter(([id]) => !locked[id]);
|
|
115
|
+
return {
|
|
116
|
+
version: 1,
|
|
117
|
+
sessionId,
|
|
118
|
+
values: Object.fromEntries(writable),
|
|
119
|
+
changes: Object.fromEntries(
|
|
120
|
+
changes.map(([id, value]) => [id, value === undefined ? [] : [value]]),
|
|
121
|
+
),
|
|
122
|
+
file: session.baseline?.file,
|
|
123
|
+
observed: Object.fromEntries(
|
|
124
|
+
changes.map(([id]) => {
|
|
125
|
+
if (!session.baseline) throw new Error("Missing session settings conflict baseline.");
|
|
126
|
+
return [id, observedSetting(session.baseline, id)];
|
|
127
|
+
}),
|
|
128
|
+
),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { mkdir, open, readFile, rename, stat, unlink } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { setTimeout } from "node:timers/promises";
|
|
5
|
+
|
|
6
|
+
// Keep this provider-independent contract aligned with pi-anthropic-compat.
|
|
7
|
+
export type SettingValue = string | number | boolean | null;
|
|
8
|
+
export type SettingValues = Record<string, SettingValue>;
|
|
9
|
+
export type SettingChanges = Record<string, SettingValue | undefined>;
|
|
10
|
+
type Document = Record<string, unknown>;
|
|
11
|
+
type FileState = { text: string | undefined; data: Document };
|
|
12
|
+
function isDocument(value: unknown): value is Document {
|
|
13
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
export type SettingsSnapshot = {
|
|
16
|
+
file: string;
|
|
17
|
+
values: SettingValues;
|
|
18
|
+
inherited: SettingValues;
|
|
19
|
+
sources: Record<string, string>;
|
|
20
|
+
global: FileState;
|
|
21
|
+
project: FileState;
|
|
22
|
+
observed?: Record<string, [string, string]>;
|
|
23
|
+
};
|
|
24
|
+
export type SettingsSessionState = {
|
|
25
|
+
changes: SettingChanges;
|
|
26
|
+
baseline?: SettingsSnapshot;
|
|
27
|
+
};
|
|
28
|
+
export function observedSetting(snapshot: SettingsSnapshot, id: string): [string, string] {
|
|
29
|
+
return (
|
|
30
|
+
snapshot.observed?.[id] ?? [
|
|
31
|
+
createHash("sha256")
|
|
32
|
+
.update(JSON.stringify(snapshot.global.data[id]) ?? "undefined")
|
|
33
|
+
.digest("hex"),
|
|
34
|
+
createHash("sha256")
|
|
35
|
+
.update(JSON.stringify(snapshot.project.data[id]) ?? "undefined")
|
|
36
|
+
.digest("hex"),
|
|
37
|
+
]
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Retain conflict evidence for session overrides across menu reopenings. */
|
|
42
|
+
export function sessionBaseline(
|
|
43
|
+
snapshot: SettingsSnapshot,
|
|
44
|
+
session: SettingsSessionState,
|
|
45
|
+
): SettingsSnapshot {
|
|
46
|
+
const baseline = session.baseline;
|
|
47
|
+
if (!baseline) return snapshot;
|
|
48
|
+
return {
|
|
49
|
+
...snapshot,
|
|
50
|
+
file: baseline.file,
|
|
51
|
+
observed: Object.fromEntries(
|
|
52
|
+
Object.keys(session.changes).map((id) => [id, observedSetting(baseline, id)]),
|
|
53
|
+
),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function read(file: string): Promise<FileState> {
|
|
58
|
+
let text: string;
|
|
59
|
+
try {
|
|
60
|
+
text = await readFile(file, "utf8");
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
63
|
+
return { text: undefined, data: {} };
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`Cannot read ${file}. Check file permissions.`, { cause: error });
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const data: unknown = JSON.parse(text);
|
|
69
|
+
if (!isDocument(data)) throw new Error("Expected an object.");
|
|
70
|
+
return { text, data };
|
|
71
|
+
} catch (error) {
|
|
72
|
+
throw new Error(`Invalid settings in ${file}. Fix the JSON object and reopen the menu.`, {
|
|
73
|
+
cause: error,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class SettingsStore {
|
|
79
|
+
readonly globalFile: string;
|
|
80
|
+
readonly projectFile: string | undefined;
|
|
81
|
+
readonly resolve: (global: Document, project: Document) => SettingValues;
|
|
82
|
+
readonly locked: Record<string, string>;
|
|
83
|
+
readonly normalize: (data: Document) => Document;
|
|
84
|
+
constructor(
|
|
85
|
+
globalFile: string,
|
|
86
|
+
projectFile: string | undefined,
|
|
87
|
+
resolve: (global: Document, project: Document) => SettingValues,
|
|
88
|
+
locked: Record<string, string> = {},
|
|
89
|
+
normalize: (data: Document) => Document = (data) => data,
|
|
90
|
+
) {
|
|
91
|
+
this.globalFile = globalFile;
|
|
92
|
+
this.projectFile = projectFile;
|
|
93
|
+
this.resolve = resolve;
|
|
94
|
+
this.locked = locked;
|
|
95
|
+
this.normalize = normalize;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async load(): Promise<SettingsSnapshot> {
|
|
99
|
+
const global = await read(this.globalFile);
|
|
100
|
+
const project = this.projectFile ? await read(this.projectFile) : { text: undefined, data: {} };
|
|
101
|
+
const isProject = this.projectFile !== undefined && project.text !== undefined;
|
|
102
|
+
const globalData = this.normalize(global.data);
|
|
103
|
+
const projectData = this.normalize(project.data);
|
|
104
|
+
const values = this.resolve(global.data, project.data);
|
|
105
|
+
const inherited = this.resolve(isProject ? global.data : {}, {});
|
|
106
|
+
const sources: Record<string, string> = {};
|
|
107
|
+
for (const id of Object.keys(values)) {
|
|
108
|
+
sources[id] = this.locked[id]
|
|
109
|
+
? `locked by ${this.locked[id]}`
|
|
110
|
+
: Object.hasOwn(projectData, id)
|
|
111
|
+
? "project"
|
|
112
|
+
: Object.hasOwn(globalData, id)
|
|
113
|
+
? "global"
|
|
114
|
+
: "default";
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
file: isProject && this.projectFile ? this.projectFile : this.globalFile,
|
|
118
|
+
values,
|
|
119
|
+
inherited,
|
|
120
|
+
sources,
|
|
121
|
+
global,
|
|
122
|
+
project,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async save(
|
|
127
|
+
baseline: SettingsSnapshot,
|
|
128
|
+
changes: SettingChanges,
|
|
129
|
+
signal: AbortSignal,
|
|
130
|
+
guard: (values: SettingValues) => void,
|
|
131
|
+
): Promise<SettingsSnapshot> {
|
|
132
|
+
const keys = Object.keys(changes);
|
|
133
|
+
if (keys.length === 0) return baseline;
|
|
134
|
+
if (baseline.file !== this.globalFile && baseline.file !== this.projectFile) {
|
|
135
|
+
throw new Error("The settings scope changed. Review the configuration before saving.");
|
|
136
|
+
}
|
|
137
|
+
if (keys.some((id) => this.locked[id])) throw new Error("Environment settings are locked.");
|
|
138
|
+
await mkdir(dirname(baseline.file), { recursive: true });
|
|
139
|
+
const lockPath = `${baseline.file}.settings-lock`;
|
|
140
|
+
let lock;
|
|
141
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
142
|
+
signal.throwIfAborted();
|
|
143
|
+
try {
|
|
144
|
+
lock = await open(lockPath, "wx", 0o600);
|
|
145
|
+
break;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "EEXIST") throw error;
|
|
148
|
+
await setTimeout(50, undefined, { signal });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!lock) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`Settings are locked at ${lockPath}. Wait for the other writer or review the stale lock.`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
const temporary = `${baseline.file}.${randomUUID()}.tmp`;
|
|
157
|
+
let temporaryCreated = false;
|
|
158
|
+
try {
|
|
159
|
+
const current = await this.load();
|
|
160
|
+
if (current.file !== baseline.file) {
|
|
161
|
+
throw new Error("The settings scope changed. Reopen the menu to review the save target.");
|
|
162
|
+
}
|
|
163
|
+
for (const id of keys) {
|
|
164
|
+
if (
|
|
165
|
+
JSON.stringify(observedSetting(current, id)) !==
|
|
166
|
+
JSON.stringify(observedSetting(baseline, id))
|
|
167
|
+
) {
|
|
168
|
+
throw new Error(`Setting ${id} changed on disk. Reopen the menu to review it.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const isProject = current.file !== this.globalFile;
|
|
172
|
+
const target = isProject ? current.project : current.global;
|
|
173
|
+
const data = { ...target.data };
|
|
174
|
+
for (const id of keys) {
|
|
175
|
+
if (changes[id] === undefined) delete data[id];
|
|
176
|
+
else data[id] = changes[id];
|
|
177
|
+
}
|
|
178
|
+
const values = this.resolve(
|
|
179
|
+
isProject ? current.global.data : data,
|
|
180
|
+
isProject ? data : current.project.data,
|
|
181
|
+
);
|
|
182
|
+
guard(values);
|
|
183
|
+
signal.throwIfAborted();
|
|
184
|
+
const mode = await stat(current.file).then(
|
|
185
|
+
(metadata) => metadata.mode & 0o777,
|
|
186
|
+
(error: unknown) => {
|
|
187
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return 0o600;
|
|
188
|
+
throw error;
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
await open(temporary, "wx", mode).then(async (handle) => {
|
|
192
|
+
temporaryCreated = true;
|
|
193
|
+
try {
|
|
194
|
+
await handle.writeFile(`${JSON.stringify(data, null, 2)}\n`);
|
|
195
|
+
await handle.sync();
|
|
196
|
+
} finally {
|
|
197
|
+
await handle.close();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
const verified = await this.load();
|
|
201
|
+
if (
|
|
202
|
+
verified.file !== current.file ||
|
|
203
|
+
verified.global.text !== current.global.text ||
|
|
204
|
+
verified.project.text !== current.project.text
|
|
205
|
+
) {
|
|
206
|
+
throw new Error("Settings changed while saving. Reopen the menu to review them.");
|
|
207
|
+
}
|
|
208
|
+
signal.throwIfAborted();
|
|
209
|
+
guard(values);
|
|
210
|
+
await rename(temporary, current.file);
|
|
211
|
+
temporaryCreated = false;
|
|
212
|
+
const saved = { text: `${JSON.stringify(data, null, 2)}\n`, data };
|
|
213
|
+
return {
|
|
214
|
+
...current,
|
|
215
|
+
values,
|
|
216
|
+
sources: {
|
|
217
|
+
...current.sources,
|
|
218
|
+
...Object.fromEntries(
|
|
219
|
+
keys.map((id) => [
|
|
220
|
+
id,
|
|
221
|
+
changes[id] === undefined
|
|
222
|
+
? isProject && Object.hasOwn(this.normalize(current.global.data), id)
|
|
223
|
+
? "global"
|
|
224
|
+
: "default"
|
|
225
|
+
: isProject
|
|
226
|
+
? "project"
|
|
227
|
+
: "global",
|
|
228
|
+
]),
|
|
229
|
+
),
|
|
230
|
+
},
|
|
231
|
+
global: isProject ? current.global : saved,
|
|
232
|
+
project: isProject ? saved : current.project,
|
|
233
|
+
};
|
|
234
|
+
} finally {
|
|
235
|
+
if (temporaryCreated) await unlink(temporary);
|
|
236
|
+
await lock.close();
|
|
237
|
+
await unlink(lockPath);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|