pi-openai-codex-compat 0.0.17 → 0.0.19

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 (26) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +62 -38
  3. package/extensions/openai-codex-compat/apply-patch.ts +7 -0
  4. package/extensions/openai-codex-compat/codex-provider/codex-provider-contracts.ts +0 -2
  5. package/extensions/openai-codex-compat/codex-provider/codex-provider-runtime.ts +58 -23
  6. package/extensions/openai-codex-compat/codex-transport/codex-transport-contracts.ts +6 -0
  7. package/extensions/openai-codex-compat/codex-transport/codex-transport-errors.ts +13 -1
  8. package/extensions/openai-codex-compat/codex-transport/codex-transport-sse-stream.ts +1 -0
  9. package/extensions/openai-codex-compat/codex-transport/codex-transport-websocket-stream.ts +1 -0
  10. package/extensions/openai-codex-compat/codex-transport.ts +1 -0
  11. package/extensions/openai-codex-compat/command-tool-contract.ts +9 -0
  12. package/extensions/openai-codex-compat/command-tools.ts +4 -0
  13. package/extensions/openai-codex-compat/compaction-checkpoint.ts +35 -145
  14. package/extensions/openai-codex-compat/config.ts +0 -24
  15. package/extensions/openai-codex-compat/footer.ts +23 -3
  16. package/extensions/openai-codex-compat/image-generation.ts +8 -0
  17. package/extensions/openai-codex-compat/index.ts +2 -2
  18. package/extensions/openai-codex-compat/pi-runtime.ts +62 -9
  19. package/extensions/openai-codex-compat/remote-compaction.ts +46 -71
  20. package/extensions/openai-codex-compat/request-options.ts +2 -9
  21. package/extensions/openai-codex-compat/request-tools-entry.ts +113 -0
  22. package/extensions/openai-codex-compat/responses-lite.ts +1 -0
  23. package/extensions/openai-codex-compat/settings-pane.ts +2 -11
  24. package/extensions/openai-codex-compat/web-run.ts +2 -0
  25. package/extensions/settings-session.ts +11 -7
  26. package/package.json +7 -7
@@ -1,5 +1,5 @@
1
1
  import { isBoolean, isString } from "./value-contracts.ts";
2
- import { createHash, randomUUID } from "node:crypto";
2
+ import { randomUUID } from "node:crypto";
3
3
  import {
4
4
  buildSessionContext,
5
5
  buildSessionProjection,
@@ -7,9 +7,9 @@ import {
7
7
  sessionEntryToContextMessages,
8
8
  type ContextEditEntry,
9
9
  type SessionEntry,
10
- type ToolInfo,
11
10
  } from "@earendil-works/pi-coding-agent";
12
11
  import {
12
+ getDeclaredTools,
13
13
  getInitialSystemMessage,
14
14
  getSystemMessageText,
15
15
  normalizeContext,
@@ -19,24 +19,17 @@ import {
19
19
  type Message,
20
20
  type Model,
21
21
  type SystemMessage,
22
+ type Tool,
22
23
  type TranscriptContext,
23
24
  } from "@earendil-works/pi-ai";
24
25
  import { Value } from "typebox/value";
25
- import { APPLY_PATCH_LARK_GRAMMAR, APPLY_PATCH_TOOL_NAME } from "./apply-patch.ts";
26
26
  import { CODEX_TOOL_CALL_PROVIDERS } from "./codex-identifiers.ts";
27
27
  import type { ImageDetail } from "./config.ts";
28
- import {
29
- installCompactionItem,
30
- isObject,
31
- requireResponsesInputItems,
32
- type JsonRecord,
33
- } from "./codex-protocol.ts";
28
+ import { installCompactionItem, isObject, requireResponsesInputItems } from "./codex-protocol.ts";
34
29
  import { nativeCommittedPrefixBeforeOverflow, nativeResponseOverrides } from "./native-history.ts";
35
- import { stableResponsesJson } from "./responses-replay.ts";
36
30
  import {
37
31
  CODEX_NAMESPACED_TOOL_NAMES,
38
32
  CODEX_TEXT_CONTENT_ITEM_TOOL_RESULT_NAMES,
39
- splitNamespacedToolName,
40
33
  } from "./namespaced-tools.ts";
41
34
  import {
42
35
  RESPONSES_COMPACTION_ITEM_SCHEMA,
@@ -45,8 +38,12 @@ import {
45
38
  type ResponsesInputItem,
46
39
  type ResponsesOutputItem,
47
40
  } from "./responses-item-schema.ts";
48
- import type { ResponsesToolDefinition } from "./responses-tool-schema.ts";
49
- import { convertResponsesMessages } from "./vendor/pi-ai/openai-responses-serialization.ts";
41
+ import {
42
+ convertResponsesMessages,
43
+ convertResponsesTools,
44
+ createGrammarToolInputProperties,
45
+ type ResponsesItem,
46
+ } from "./vendor/pi-ai/openai-responses-serialization.ts";
50
47
 
51
48
  type AgentMessages = ReturnType<typeof sessionEntryToContextMessages>;
52
49
 
@@ -113,148 +110,41 @@ export function requestInstructions(context: TranscriptContext): string {
113
110
  return initial ? getSystemMessageText(initial) : "";
114
111
  }
115
112
 
113
+ /** A session branch as the Pi AI messages Pi sends, including the system messages that declare tools. */
114
+ export function branchTranscript(branch: readonly SessionEntry[]): Message[] {
115
+ return convertToLlm(buildSessionContext([...branch]).messages);
116
+ }
117
+
116
118
  /** `instructions` for a request built from a session branch rather than Pi's live transcript. */
117
119
  export function branchInstructions(model: Model<Api>, branch: readonly SessionEntry[]): string {
118
120
  return requestInstructions(
119
- resolveRequestTranscript(model, {
120
- messages: convertToLlm(buildSessionContext([...branch]).messages),
121
- }),
121
+ resolveRequestTranscript(model, { messages: branchTranscript(branch) }),
122
122
  );
123
123
  }
124
124
 
125
- function responsesToolParameters(tool: ToolInfo): JsonRecord {
126
- if (!isObject(tool.parameters)) {
127
- throw new Error(`Tool ${tool.name} must have JSON object parameters.`);
128
- }
129
- return tool.parameters;
130
- }
131
-
132
- function asResponsesTool(
133
- tool: ToolInfo,
134
- grammarToolInputProperties: GrammarToolInputProperties,
135
- ): ResponsesToolDefinition {
136
- if (tool.name === APPLY_PATCH_TOOL_NAME && grammarToolInputProperties.has(tool.name)) {
137
- return {
138
- type: "custom",
139
- name: tool.name,
140
- description: tool.description,
141
- format: {
142
- type: "grammar",
143
- syntax: "lark",
144
- definition: APPLY_PATCH_LARK_GRAMMAR,
145
- },
146
- };
147
- }
148
- const namespaced = splitNamespacedToolName(tool.name);
149
- if (namespaced) {
150
- return {
151
- type: "namespace",
152
- name: namespaced.namespace,
153
- description: `Tools in the ${namespaced.namespace} namespace.`,
154
- tools: [
155
- {
156
- type: "function",
157
- name: namespaced.name,
158
- description: tool.description,
159
- parameters: responsesToolParameters(tool),
160
- strict: false,
161
- },
162
- ],
163
- };
164
- }
165
- return {
166
- type: "function",
167
- name: tool.name,
168
- description: tool.description,
169
- parameters: responsesToolParameters(tool),
170
- strict: false,
171
- };
172
- }
173
-
174
- export function activeResponsesTools(
175
- allTools: readonly ToolInfo[],
176
- activeNames: readonly string[],
177
- grammarToolInputProperties: GrammarToolInputProperties = new Map(),
178
- ): ResponsesToolDefinition[] | undefined {
179
- const enabled = new Set(activeNames);
180
- const tools = allTools.filter((tool) => enabled.has(tool.name));
181
- return tools.length > 0
182
- ? tools.map((tool) => asResponsesTool(tool, grammarToolInputProperties))
183
- : undefined;
184
- }
185
-
186
- /** The raw tool definition fields that both Pi AI's `Tool` and Pi's `ToolInfo` carry. */
187
- export type ToolDeclarationSource = Pick<ToolInfo, "name" | "description" | "parameters">;
188
-
189
125
  /**
190
- * Fingerprint raw tool definitions by name, description, and parameter schema,
191
- * independent of declaration order. Both the transcript tools a turn request
192
- * declared and the registry's active `ToolInfo` list produce the same value for
193
- * the same definitions, so a cached turn template can be compared with the
194
- * current tools without reversing the strict schema conversion applied on the
195
- * wire.
126
+ * The top-level `tools` of a Codex request declaring the given transcript tools.
127
+ * Turn requests and native compaction share this conversion, so the same tools
128
+ * produce the same declarations and the same prompt-cache prefix.
196
129
  */
197
- export function toolDefinitionFingerprint(tools: readonly ToolDeclarationSource[]): string {
198
- const declarations = tools
199
- .map((tool) => {
200
- // The JSON round-trip drops typebox symbol keys and undefined fields, as
201
- // Pi AI's own declaration comparison does.
202
- const parameters: unknown = JSON.parse(JSON.stringify(tool.parameters));
203
- return { name: tool.name, description: tool.description, parameters };
204
- })
205
- .sort((left, right) => left.name.localeCompare(right.name));
206
- return createHash("sha256").update(stableResponsesJson(declarations)).digest("hex");
207
- }
208
-
209
- /** Fingerprint of the registry tools that are currently active. */
210
- export function activeToolFingerprint(
211
- allTools: readonly ToolInfo[],
212
- activeNames: readonly string[],
213
- ): string {
214
- const enabled = new Set(activeNames);
215
- return toolDefinitionFingerprint(allTools.filter((tool) => enabled.has(tool.name)));
130
+ export function requestTools(model: Model<Api>, tools: readonly Tool[]): ResponsesItem[] {
131
+ const compat = responsesCompatibility(model.compat);
132
+ return convertResponsesTools(tools, {
133
+ strict: false,
134
+ supportsStrictMode: compat.supportsStrictMode ?? true,
135
+ supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools ?? false,
136
+ namespacedToolNames: CODEX_NAMESPACED_TOOL_NAMES,
137
+ });
216
138
  }
217
139
 
218
- /**
219
- * Whether a cached turn template still declares exactly the active tools.
220
- *
221
- * The serialized `tools` list must name the active set (namespace members
222
- * count as `namespace.member`), and the raw definitions that produced it must
223
- * fingerprint identically to the active registry tools. Comparing names and
224
- * descriptions alone missed parameter-schema changes under an unchanged name.
225
- *
226
- * A cached turn template carries the exact declarations the turn requests sent,
227
- * including strict schema conversion that `ToolInfo` cannot reproduce. Reusing
228
- * it keeps the compaction request on the turn requests' prompt-cache prefix.
229
- */
230
- export function declaresActiveTools(
231
- serialized: readonly JsonRecord[],
232
- cachedToolFingerprint: string,
233
- allTools: readonly ToolInfo[],
234
- activeNames: readonly string[],
235
- ): boolean {
236
- if (cachedToolFingerprint !== activeToolFingerprint(allTools, activeNames)) return false;
237
- const declared = new Map<string, string | undefined>();
238
- for (const item of serialized) {
239
- if (item.type === "namespace" && isString(item.name) && Array.isArray(item.tools)) {
240
- for (const member of item.tools) {
241
- if (!isObject(member) || !isString(member.name)) return false;
242
- declared.set(
243
- `${item.name}.${member.name}`,
244
- isString(member["description"]) ? member["description"] : undefined,
245
- );
246
- }
247
- } else if ((item.type === "function" || item.type === "custom") && isString(item.name)) {
248
- declared.set(item.name, isString(item["description"]) ? item["description"] : undefined);
249
- } else {
250
- return false;
251
- }
252
- }
253
- const enabled = new Set(activeNames);
254
- const active = allTools.filter((tool) => enabled.has(tool.name));
255
- return (
256
- declared.size === active.length &&
257
- active.every((tool) => declared.has(tool.name) && declared.get(tool.name) === tool.description)
140
+ /** Input properties of the grammar tools a transcript ever declared, for replaying their calls. */
141
+ export function requestGrammarToolInputProperties(
142
+ model: Model<Api>,
143
+ messages: readonly Message[],
144
+ ): GrammarToolInputProperties {
145
+ return createGrammarToolInputProperties(
146
+ getDeclaredTools([...messages]),
147
+ responsesCompatibility(model.compat).supportsOpenAIGrammarTools ?? false,
258
148
  );
259
149
  }
260
150
 
@@ -24,12 +24,6 @@ export const REASONING_SUMMARY_SCHEMA = {
24
24
 
25
25
  export type ReasoningSummary = Static<typeof REASONING_SUMMARY_SCHEMA>;
26
26
 
27
- export const REASONING_MODE_SCHEMA = {
28
- enum: ["standard", "pro"],
29
- } as const;
30
-
31
- export type ReasoningMode = Static<typeof REASONING_MODE_SCHEMA>;
32
-
33
27
  export const IMAGE_DETAIL_SCHEMA = {
34
28
  enum: ["auto", "low", "high", "original"],
35
29
  } as const;
@@ -80,7 +74,6 @@ export interface CodexCompatConfig {
80
74
  webSearch: WebSearchMode;
81
75
  textVerbosity: TextVerbosity;
82
76
  reasoningSummary: ReasoningSummary;
83
- reasoningMode: ReasoningMode;
84
77
  }
85
78
 
86
79
  export const CONFIG_ENVIRONMENT_VARIABLES = {
@@ -98,7 +91,6 @@ export const CONFIG_ENVIRONMENT_VARIABLES = {
98
91
  webSearch: `${ENV_PREFIX}WEB_SEARCH_MODE`,
99
92
  textVerbosity: `${ENV_PREFIX}TEXT_VERBOSITY`,
100
93
  reasoningSummary: `${ENV_PREFIX}REASONING_SUMMARY`,
101
- reasoningMode: `${ENV_PREFIX}REASONING_MODE`,
102
94
  } as const satisfies Record<keyof CodexCompatConfig, string>;
103
95
 
104
96
  export type ConfigLayer = {
@@ -116,7 +108,6 @@ export type ConfigLayer = {
116
108
  webSearch?: WebSearchMode;
117
109
  textVerbosity?: TextVerbosity;
118
110
  reasoningSummary?: ReasoningSummary;
119
- reasoningMode?: ReasoningMode;
120
111
  };
121
112
 
122
113
  export const CONFIG_FILE = "openai-codex-compat.json";
@@ -134,7 +125,6 @@ export const DEFAULT_CONFIG: CodexCompatConfig = {
134
125
  webSearch: "disabled",
135
126
  textVerbosity: "low",
136
127
  reasoningSummary: "auto",
137
- reasoningMode: "standard",
138
128
  };
139
129
 
140
130
  type Environment = Readonly<Record<string, string | undefined>>;
@@ -277,13 +267,6 @@ export function parseEnvironmentConfig(environment: Environment = process.env):
277
267
  );
278
268
  if (reasoningSummary !== undefined) layer.reasoningSummary = reasoningSummary;
279
269
 
280
- const reasoningMode = environmentEnum(
281
- environment,
282
- CONFIG_ENVIRONMENT_VARIABLES.reasoningMode,
283
- REASONING_MODE_SCHEMA,
284
- );
285
- if (reasoningMode !== undefined) layer.reasoningMode = reasoningMode;
286
-
287
270
  return layer;
288
271
  }
289
272
 
@@ -357,11 +340,6 @@ export function parseConfig(value: unknown): ConfigLayer {
357
340
  layer.reasoningSummary = reasoningSummary;
358
341
  }
359
342
 
360
- const reasoningMode = value["reasoningMode"];
361
- if (Value.Check(REASONING_MODE_SCHEMA, reasoningMode)) {
362
- layer.reasoningMode = reasoningMode;
363
- }
364
-
365
343
  return layer;
366
344
  }
367
345
 
@@ -404,7 +382,6 @@ export function resolveConfig(
404
382
  if (merged.webSearch) config.webSearch = merged.webSearch;
405
383
  if (merged.textVerbosity) config.textVerbosity = merged.textVerbosity;
406
384
  if (merged.reasoningSummary) config.reasoningSummary = merged.reasoningSummary;
407
- if (merged.reasoningMode) config.reasoningMode = merged.reasoningMode;
408
385
  return config;
409
386
  }
410
387
 
@@ -448,7 +425,6 @@ export function configLayer(config: CodexCompatConfig): ConfigLayer {
448
425
  webSearch: config.webSearch,
449
426
  textVerbosity: config.textVerbosity,
450
427
  reasoningSummary: config.reasoningSummary,
451
- reasoningMode: config.reasoningMode,
452
428
  };
453
429
  }
454
430
 
@@ -4,6 +4,7 @@ import {
4
4
  type ExtensionContext,
5
5
  type ReadonlyFooterDataProvider,
6
6
  type SessionEntry,
7
+ type SessionManager,
7
8
  } from "@earendil-works/pi-coding-agent";
8
9
  import type { Component } from "@earendil-works/pi-tui";
9
10
  import type { CodexCompatConfig } from "./config.ts";
@@ -42,7 +43,6 @@ export type FooterContext = ConfigContext &
42
43
  export function footerSettingLabels(config: CodexCompatConfig): string[] {
43
44
  return [
44
45
  config.fastMode ? "fast" : undefined,
45
- config.reasoningMode === "pro" ? "pro" : undefined,
46
46
  config.textVerbosity !== "low" ? `verbosity ${config.textVerbosity}` : undefined,
47
47
  config.reasoningSummary !== "auto" ? `summary ${config.reasoningSummary}` : undefined,
48
48
  ].filter((label): label is string => label !== undefined);
@@ -69,14 +69,23 @@ export function footerModel(
69
69
  return { ...model, id, reasoning: false };
70
70
  }
71
71
 
72
+ /**
73
+ * The part of Pi's `AgentSession` that `FooterComponent` reads. Pi constructs the
74
+ * footer with its own session, so the compiler cannot check this adapter against
75
+ * the footer's actual reads; the packaged TUI test renders it through Pi.
76
+ */
72
77
  interface FooterSessionAdapter {
73
78
  readonly state: {
74
79
  model: Model<Api> | undefined;
75
80
  thinkingLevel: string;
76
81
  };
82
+ /** Pi keys its cached session statistics on this identity. */
83
+ readonly model: Model<Api> | undefined;
84
+ /** Extensions cannot observe virtual-model routing, so the footer omits it. */
85
+ readonly routedModel: undefined;
77
86
  sessionManager: Pick<
78
- ExtensionContext["sessionManager"],
79
- "getEntries" | "getCwd" | "getSessionName"
87
+ SessionManager,
88
+ "getEntries" | "getEntryCount" | "getCwd" | "getLeafId" | "getSessionId" | "getSessionName"
80
89
  >;
81
90
  getContextUsage: () => ReturnType<ExtensionContext["getContextUsage"]>;
82
91
  modelRuntime: {
@@ -190,13 +199,20 @@ class CachedFooterSession implements FooterSessionAdapter {
190
199
 
191
200
  readonly sessionManager: FooterSessionAdapter["sessionManager"];
192
201
  readonly modelRuntime: FooterSessionAdapter["modelRuntime"];
202
+ readonly routedModel = undefined;
193
203
 
194
204
  constructor(ctx: FooterContext, resolveConfig: ConfigResolver) {
195
205
  this.ctx = ctx;
196
206
  this.resolveConfig = resolveConfig;
207
+ // Pi's footer caches its statistics by session id, leaf id, entry count, and
208
+ // model. The aggregated snapshot is keyed by the same session and leaf, so
209
+ // both caches refresh together.
197
210
  this.sessionManager = {
198
211
  getEntries: () => this.getSnapshot().entries,
212
+ getEntryCount: () => this.getSnapshot().entries.length,
199
213
  getCwd: () => ctx.sessionManager.getCwd(),
214
+ getLeafId: () => ctx.sessionManager.getLeafId(),
215
+ getSessionId: () => ctx.sessionManager.getSessionId(),
200
216
  getSessionName: () => this.getSnapshot().displayName,
201
217
  };
202
218
  this.modelRuntime = {
@@ -220,6 +236,10 @@ class CachedFooterSession implements FooterSessionAdapter {
220
236
  };
221
237
  }
222
238
 
239
+ get model(): Model<Api> | undefined {
240
+ return selectedRegistryModel(this.ctx);
241
+ }
242
+
223
243
  get state(): FooterSessionAdapter["state"] {
224
244
  const model = selectedRegistryModel(this.ctx);
225
245
  return {
@@ -288,6 +288,14 @@ export default function registerImageGeneration(
288
288
  parameters: IMAGE_GENERATION_PARAMETERS,
289
289
  executionMode: "sequential",
290
290
  renderShell: "self",
291
+ // Each call adds a new image file and refuses to replace a different one. The request goes
292
+ // to the OpenAI image service.
293
+ annotations: {
294
+ readOnlyHint: false,
295
+ destructiveHint: false,
296
+ idempotentHint: false,
297
+ openWorldHint: true,
298
+ },
291
299
  async execute(toolCallId, params, signal, onUpdate, ctx) {
292
300
  const model = ctx.model;
293
301
  if (!isCodexModel(model)) {
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import * as piAi from "@earendil-works/pi-ai";
3
3
  import * as piCodingAgent from "@earendil-works/pi-coding-agent";
4
- import { requirePiTranscriptRuntime } from "./pi-runtime.ts";
4
+ import { requirePiRuntime } from "./pi-runtime.ts";
5
5
  import { DEFAULT_CONFIG, loadConfig, type CodexCompatConfig } from "./config.ts";
6
6
  import type { ConfigContext } from "./config-context.ts";
7
7
  import { registerCodexProvider } from "./codex-provider.ts";
@@ -24,7 +24,7 @@ export {
24
24
  } from "./codex-transport.ts";
25
25
 
26
26
  export default function registerOpenAICodexCompat(pi: ExtensionAPI): void {
27
- requirePiTranscriptRuntime(piAi, piCodingAgent);
27
+ requirePiRuntime(piAi, piCodingAgent, pi);
28
28
  let activeConfig: CodexCompatConfig | undefined;
29
29
  let sessionContext: Parameters<typeof loadSessionConfig>[0] | undefined;
30
30
  const resolveConfig = (ctx: ConfigContext): CodexCompatConfig => {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Every Pi 0.86 transcript API this extension imports as a value from
2
+ * Every transcript API this extension imports as a value from
3
3
  * `@earendil-works/pi-ai`. Pi's extension loader turns a missing named import
4
4
  * into `undefined` rather than a load error, so an older host fails at first use
5
5
  * unless the surface is checked up front. Keep this list equal to the value
@@ -18,27 +18,80 @@ export const TRANSCRIPT_APIS = [
18
18
  ] as const;
19
19
 
20
20
  /**
21
- * Every Pi 0.87 session API this extension imports as a value from
21
+ * Every session API this extension imports as a value from
22
22
  * `@earendil-works/pi-coding-agent`. Keep this list equal to the value imports
23
23
  * in `compaction-checkpoint.ts`.
24
24
  */
25
25
  export const SESSION_APIS = ["buildSessionProjection"] as const;
26
26
 
27
- export const REQUIRED_PI = "Pi 0.87.x";
27
+ /**
28
+ * Host APIs that first shipped in Pi 0.99: the footer's `SessionManager.getEntryCount()`
29
+ * and the extension API's `getSettings()`. They prove the loaded runtime is 0.99 even when
30
+ * `PI_PACKAGE_DIR` points package metadata at another installation.
31
+ */
32
+ export const PI_099_APIS = ["SessionManager.getEntryCount", "ExtensionAPI.getSettings"] as const;
33
+
34
+ export const MINIMUM_PI_VERSION = "0.99.1";
35
+ export const REQUIRED_PI = `Pi ${MINIMUM_PI_VERSION} or later`;
36
+
37
+ function isFunction(value: unknown): boolean {
38
+ return typeof value === "function";
39
+ }
40
+
41
+ function prototypeMethod(value: unknown, name: string): unknown {
42
+ if (typeof value !== "function") return undefined;
43
+ const prototype: unknown = value.prototype;
44
+ return typeof prototype === "object" && prototype !== null
45
+ ? Reflect.get(prototype, name)
46
+ : undefined;
47
+ }
28
48
 
29
- /** Check the loaded host, not package metadata that PI_PACKAGE_DIR can override. */
30
- export function requirePiTranscriptRuntime(
49
+ /** Compare dotted numeric release versions; prerelease suffixes rank below their release. */
50
+ export function atLeastVersion(version: unknown, minimum: string): boolean {
51
+ if (typeof version !== "string") return false;
52
+ const match = /^(\d+)\.(\d+)\.(\d+)(-.+)?$/.exec(version);
53
+ if (!match) return false;
54
+ const actual = [Number(match[1]), Number(match[2]), Number(match[3])];
55
+ const required = minimum.split(".").map(Number);
56
+ for (let index = 0; index < 3; index++) {
57
+ const left = actual[index] ?? 0;
58
+ const right = required[index] ?? 0;
59
+ if (left !== right) return left > right;
60
+ }
61
+ return match[4] === undefined;
62
+ }
63
+
64
+ /**
65
+ * Require the Pi 0.99 host surface and a reported version of at least 0.99.1. The version comes
66
+ * from package metadata, so the API checks also run to catch an older executable whose
67
+ * `PI_PACKAGE_DIR` names a newer package.
68
+ */
69
+ export function requirePiRuntime(
31
70
  transcriptApi: Record<string, unknown>,
32
71
  sessionApi: Record<string, unknown>,
72
+ extensionApi: { readonly getSettings?: unknown },
33
73
  ): void {
34
74
  const missing = [
35
- ...TRANSCRIPT_APIS.filter((name) => typeof transcriptApi[name] !== "function"),
36
- ...SESSION_APIS.filter((name) => typeof sessionApi[name] !== "function"),
75
+ ...TRANSCRIPT_APIS.filter((name) => !isFunction(transcriptApi[name])),
76
+ ...SESSION_APIS.filter((name) => !isFunction(sessionApi[name])),
77
+ ...(isFunction(prototypeMethod(sessionApi["SessionManager"], "getEntryCount"))
78
+ ? []
79
+ : ["SessionManager.getEntryCount"]),
80
+ ...(isFunction(extensionApi.getSettings) ? [] : ["ExtensionAPI.getSettings"]),
81
+ ];
82
+ const version = sessionApi["VERSION"];
83
+ const problems = [
84
+ ...(missing.length > 0 ? [`Missing host APIs: ${missing.join(", ")}.`] : []),
85
+ ...(atLeastVersion(version, MINIMUM_PI_VERSION)
86
+ ? []
87
+ : [
88
+ `The host reports Pi ${typeof version === "string" ? version : "with no version"}, below ${MINIMUM_PI_VERSION}.`,
89
+ ]),
37
90
  ];
38
- if (missing.length > 0) {
91
+ if (problems.length > 0) {
39
92
  throw new Error(
40
93
  `pi-openai-codex-compat requires a running ${REQUIRED_PI} runtime. ` +
41
- `Missing host APIs: ${missing.join(", ")}. ` +
94
+ `${problems.join(" ")} ` +
42
95
  `Exit Pi and start ${REQUIRED_PI} after updating. /reload cannot upgrade the running ` +
43
96
  "runtime, and PI_PACKAGE_DIR can make an older executable report a newer version.",
44
97
  );