pi-openai-toolkit 0.14.9 → 0.14.11

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/docs/internals.md CHANGED
@@ -52,6 +52,7 @@ sequenceDiagram
52
52
  Toolkit->>Toolkit: verify successful notes write in this window
53
53
  Toolkit-->>Model: checkpoint receipt with the successful note path
54
54
  Toolkit-->>Pi: window marker appended, previous window trim scheduled
55
+ Note over Model: Context switch is complete; read the receipt once, then resume the active task
55
56
  Model->>Backend: next request with the new x-codex-window-id
56
57
  Pi->>Toolkit: session_before_compact
57
58
  Toolkit-->>Pi: no-summary boundary consuming the scheduled trim
@@ -62,6 +63,7 @@ State invariants the lifecycle code must keep honest:
62
63
  - The checkpoint gate verifies a successful `notes` `append_to_file` or `write_file` against the persisted session branch after the latest window boundary, so it survives restarts and forks. A response with `ok: false` or `success: false` is not successful, even when HTTP returns 200.
63
64
  - The gate uses the live session/branch identity from `ctx.sessionManager`; cached manager identity must not hide a valid current-window pair, and old or foreign-session evidence must remain rejected.
64
65
  - Every rollover carries the most recent successful checkpoint path in its handoff message. The optional `thread_hint` is supplemental; a hint failure must not erase the local receipt.
66
+ - A successful rollover closes the pre-rollover checkpoint phase. The first turn in the new window reads the receipt once and resumes the active user task; it must not immediately create another checkpoint or call `new_context` as part of that handoff. A later checkpoint is only for a later rollover.
65
67
  - `new_context` has no force escape hatch: a persisted successful notes checkpoint in the current window is always required before rollover. The model must wait for the notes result to be persisted before retrying.
66
68
  - After a marker is accepted for sending, the manager keeps a session/window-anchored pending guard until that exact target marker is persisted or the session changes. A duplicate `new_context` during this persistence gap returns `started: false` and sends no second marker; projected/in-memory messages do not retire the guard.
67
69
  - Budget checks are skipped until the current window has produced its own assistant usage. Acting on the previous window's usage anchor would burn the once-per-window reminder on a false alarm.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openai-toolkit",
3
- "version": "0.14.9",
3
+ "version": "0.14.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -32,20 +32,7 @@
32
32
  "codex",
33
33
  "responses"
34
34
  ],
35
- "pi": {
36
- "extensions": [
37
- "./extensions/compaction.ts",
38
- "./extensions/web-search.ts",
39
- "./extensions/image-generation.ts",
40
- "./extensions/auto-mode.ts",
41
- "./extensions/codex-astra.ts"
42
- ]
43
- },
44
35
  "files": [
45
- "extensions/compaction.ts",
46
- "extensions/web-search.ts",
47
- "extensions/image-generation.ts",
48
- "extensions/auto-mode.ts",
49
36
  "src/compact-client.ts",
50
37
  "src/compaction-projection.ts",
51
38
  "src/pi-context-hook.ts",
@@ -81,7 +68,6 @@
81
68
  "src/auto-mode/reviewer.ts",
82
69
  "src/auto-mode/classifier.ts",
83
70
  "src/auto-mode/types.ts",
84
- "extensions/codex-astra.ts",
85
71
  "src/codex-astra/effort-planner.ts",
86
72
  "src/codex-astra/extension.ts",
87
73
  "src/model-scope.ts",
@@ -120,10 +106,10 @@
120
106
  "@earendil-works/pi-tui": ">=0.85.1"
121
107
  },
122
108
  "devDependencies": {
123
- "@earendil-works/pi-agent-core": "0.85.1",
124
- "@earendil-works/pi-ai": "0.85.1",
125
- "@earendil-works/pi-coding-agent": "0.85.1",
126
- "@earendil-works/pi-tui": "0.85.1",
109
+ "@earendil-works/pi-agent-core": "0.86.0",
110
+ "@earendil-works/pi-ai": "0.86.0",
111
+ "@earendil-works/pi-coding-agent": "0.86.0",
112
+ "@earendil-works/pi-tui": "0.86.0",
127
113
  "@types/node": "24.12.4",
128
114
  "bun": "1.2.23",
129
115
  "typescript": "5.9.3"
@@ -1,4 +1,12 @@
1
- import type { Api, AssistantMessage, Context, Message, Model, Tool } from "@earendil-works/pi-ai";
1
+ import type {
2
+ Api,
3
+ AssistantMessage,
4
+ Context,
5
+ Message,
6
+ Model,
7
+ Tool,
8
+ ToolCall,
9
+ } from "@earendil-works/pi-ai";
2
10
  import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
3
11
  import { parseModelSpec } from "../runtime";
4
12
  import { backfillVerdictFields, buildReviewPrompt, reviewerSystemPrompt } from "./prompt";
@@ -93,11 +101,13 @@ function firstTextBlock(message: AssistantMessage): string {
93
101
  .trim();
94
102
  }
95
103
 
96
- function toolCallBlocks(message: AssistantMessage) {
97
- return message.content.filter(
98
- (block): block is { type: "toolCall"; id: string; name: string; arguments: Record<string, unknown> } =>
99
- block.type === "toolCall",
100
- );
104
+ /**
105
+ * Pi 0.85 typed tool-call arguments as `Record<string, any>` and Pi 0.86 tightened
106
+ * them to the public `JsonObject`. Narrow with Pi's exported `ToolCall` instead of a
107
+ * handwritten shape so the guard tracks whichever contract the installed Pi declares.
108
+ */
109
+ function toolCallBlocks(message: AssistantMessage): ToolCall[] {
110
+ return message.content.filter((block): block is ToolCall => block.type === "toolCall");
101
111
  }
102
112
 
103
113
  type CompletionAttempt = {
@@ -35,7 +35,7 @@ export const CONTEXT_WINDOW_COMPACTION_SUMMARY =
35
35
  "[Pi Codex context-window boundary; no conversation summary was generated.]";
36
36
 
37
37
  const CONTEXT_WINDOW_GUIDANCE = `<context_window_guidance>
38
- Checkpoint the active request, known history IDs, decisions, progress, learnings and next steps in notes before new_context, and wait for that notes result to be persisted; only a persisted successful result in the current window unlocks the rollover, and no summary carries over. If new_context reports that a rollover is already scheduled, do not call it again in the same window. After rollover, read the checkpoint receipt note first when present; use a thread hint only as supplemental guidance. Use history only for a missing detail.
38
+ Before calling new_context, checkpoint the active request, known history IDs, decisions, progress, learnings and next steps in notes, and wait for that result to be persisted; only a persisted successful result in the current window unlocks the rollover. If new_context reports that a rollover is already scheduled, do not call it again in the same window. When this message includes a completed context-switch handoff, follow that post-rollover section first instead of restarting the pre-rollover checkpoint steps. A thread hint is supplemental and must not replace the local checkpoint receipt. No conversation summary carries over, and history is only for missing details.
39
39
  </context_window_guidance>`;
40
40
 
41
41
  export function renderContextWindowMessage(
@@ -51,8 +51,11 @@ export function renderContextWindowMessage(
51
51
  ];
52
52
  if (identity.previousWindowId) lines.push(`Previous context window id: ${identity.previousWindowId}`);
53
53
  if (checkpoint) {
54
+ lines.push("Context switch completed. This is the first turn in the new context window.");
54
55
  lines.push("Checkpoint successfully written:");
55
56
  lines.push(` Read this note before doing anything else with notes action "read_file": ${JSON.stringify(checkpoint.path)}`);
57
+ lines.push("After reading the checkpoint receipt, resume the active user task.");
58
+ lines.push("Do not immediately create another checkpoint or call new_context as part of this handoff. Only prepare a new checkpoint when a later context rollover is actually needed.");
56
59
  }
57
60
  if (threadHint) lines.push(threadHint);
58
61
  lines.push("</context_window>");
@@ -12,13 +12,25 @@ import type { HistoryAction, NotesAction } from "./types";
12
12
 
13
13
  const EMPTY_PARAMETERS = Type.Object({}, { additionalProperties: false });
14
14
 
15
- function arrayEquals(a: readonly string[] | undefined, b: readonly string[] | undefined): boolean {
16
- if (a === b) return true;
17
- if (!a || !b || a.length !== b.length) return false;
18
- for (let i = 0; i < a.length; i++) {
19
- if (a[i] !== b[i]) return false;
20
- }
21
- return true;
15
+ type PublishedTool = {
16
+ name: string;
17
+ description: string;
18
+ parameters: unknown;
19
+ promptGuidelines?: string[];
20
+ };
21
+
22
+ /**
23
+ * Pi's ToolInfo wrapper is new, but its definition fields retain the objects
24
+ * registered by the extension. Keep identity checks for those fields: an exact
25
+ * foreign clone under the same name is still not ownership of our tool.
26
+ */
27
+ function isOurTool(
28
+ actual: PublishedTool,
29
+ expected: { description: string; parameters: unknown; promptGuidelines?: string[] },
30
+ ): boolean {
31
+ return actual.description === expected.description
32
+ && actual.parameters === expected.parameters
33
+ && actual.promptGuidelines === expected.promptGuidelines;
22
34
  }
23
35
  const HISTORY_ACTIONS = Object.keys(HISTORY_ENDPOINTS) as [HistoryAction, ...HistoryAction[]];
24
36
  const NOTES_ACTIONS = Object.keys(NOTES_ENDPOINTS) as [NotesAction, ...NotesAction[]];
@@ -94,7 +106,8 @@ export function createContextManagementTools(
94
106
  parameters: NEW_CONTEXT_PARAMETERS,
95
107
  promptSnippet: "Start a new remote Codex context window without summarizing history.",
96
108
  promptGuidelines: [
97
- "Checkpoint active work in notes before calling new_context; no conversation summary carries over. Only a persisted successful notes append/write in the current window unlocks the rollover.",
109
+ "Before this new_context call, checkpoint active work in notes; only a persisted successful notes append/write in the current window unlocks this rollover, and no conversation summary carries over.",
110
+ "A successful new_context completes one context switch. In the next window, read the checkpoint receipt first when present, then resume the active user task; do not immediately create another checkpoint or call new_context as part of that handoff.",
98
111
  "Wait for the notes tool result before calling new_context; an in-flight or failed write is not a checkpoint. If new_context reports that a rollover is already scheduled, do not call it again in the same window.",
99
112
  ],
100
113
  executionMode: "sequential",
@@ -116,7 +129,12 @@ export function createContextManagementTools(
116
129
  trimPreviousWindow: true,
117
130
  });
118
131
  return {
119
- content: [{ type: "text", text: started ? "A new context window will start without summarizing conversation history." : "A new context window is already scheduled." }],
132
+ content: [{
133
+ type: "text",
134
+ text: started
135
+ ? "Context switch scheduled successfully. In the next context window, read the checkpoint receipt first when present, then resume the active user task; do not immediately create another checkpoint or call new_context."
136
+ : "A new context window is already scheduled.",
137
+ }],
120
138
  details: { started },
121
139
  };
122
140
  },
@@ -161,6 +179,7 @@ export function createContextManagementTools(
161
179
  promptSnippet: "Read and checkpoint remote Codex notes across context windows.",
162
180
  promptGuidelines: [
163
181
  "Before calling new_context, checkpoint the current turn's active work (unfinished tasks, decisions, open questions, references) into notes with append_to_file or write_file so it survives the window change; wait for that result to be persisted before calling new_context.",
182
+ "After a successful new_context handoff, read the checkpoint receipt first when present and resume the active user task; do not immediately create another checkpoint or call new_context unless a later rollover is actually needed.",
164
183
  "When a large task spans multiple context windows, keep a running note per line of work and read it at the start of each new window; append new state instead of replacing it unless the note is stale.",
165
184
  "Prefer read_file or search_contents for looking things up; reserve write_file for explicit rewrite/clear and append_to_file for incremental state.",
166
185
  "Keep note text concise and self-contained: it may be read later without the rest of the conversation, so include identifiers and verbatim key decisions, not hearsay summaries.",
@@ -175,6 +194,13 @@ export function createContextManagementTools(
175
194
  return { newContext, getContextRemaining, history, notes };
176
195
  }
177
196
 
197
+ export type ContextToolRegistrationState = "verified" | "conflict" | "unverified";
198
+
199
+ export type ContextToolSyncResult = {
200
+ synced: boolean;
201
+ registrationState: ContextToolRegistrationState;
202
+ };
203
+
178
204
  export class ContextManagementToolController {
179
205
  private readonly registeredNames = new Set<string>();
180
206
  private readonly definitions = new Map<string, {
@@ -183,8 +209,7 @@ export class ContextManagementToolController {
183
209
  promptGuidelines?: string[];
184
210
  }>();
185
211
  private registered = false;
186
- private registrationChecked = false;
187
- private registrationValid = false;
212
+ private verifiedOnce = false;
188
213
 
189
214
  constructor(private readonly pi: ExtensionAPI) {}
190
215
 
@@ -198,12 +223,16 @@ export class ContextManagementToolController {
198
223
  // those are bound only after extension loading completes.
199
224
  try {
200
225
  for (const definition of definitions) api.registerTool(definition);
226
+ this.registered = true;
201
227
  } catch {
228
+ // Pi rejects a registration whose name is already owned elsewhere.
229
+ this.registered = false;
202
230
  return false;
203
231
  }
204
232
 
205
233
  this.registeredNames.clear();
206
234
  this.definitions.clear();
235
+ this.verifiedOnce = false;
207
236
  for (const definition of definitions) {
208
237
  this.registeredNames.add(definition.name);
209
238
  this.definitions.set(definition.name, {
@@ -212,43 +241,58 @@ export class ContextManagementToolController {
212
241
  promptGuidelines: definition.promptGuidelines,
213
242
  });
214
243
  }
215
- this.registered = true;
216
- this.registrationChecked = false;
217
- this.registrationValid = false;
244
+ // A successful call only proves the hand-off; it does not prove the runtime
245
+ // published the definitions. `checkRegistration()` reads that from Pi.
218
246
  return true;
219
247
  }
220
248
 
221
- private verifyRegistration(): boolean {
222
- if (!this.registered) return false;
223
- if (this.registrationChecked) return this.registrationValid;
224
- this.registrationChecked = true;
249
+ /**
250
+ * Read registration state until the runtime confirms publication.
251
+ *
252
+ * Pi 0.86 can reject action methods while a session replacement is still
253
+ * binding, and can publish the registry after `session_start` ran. Negative
254
+ * reads remain retryable; only a successful verification is cached for this
255
+ * controller instance. A Pi reload creates a new controller.
256
+ */
257
+ checkRegistration(): ContextToolRegistrationState {
258
+ if (!this.registered) return "conflict";
259
+ // A confirmed publication is kept: the expensive part was never the read, it
260
+ // was trusting a failed one. Only negative verdicts are recomputed each time.
261
+ if (this.verifiedOnce) return "verified";
262
+ let available: PublishedTool[];
225
263
  try {
226
- const api = this.pi as ExtensionAPI & { getAllTools?: () => Array<{
227
- name: string;
228
- description: string;
229
- parameters: unknown;
230
- promptGuidelines?: string[];
231
- }> };
232
- if (typeof api.getAllTools !== "function") return false;
233
- const available = new Map(api.getAllTools().map((tool) => [tool.name, tool]));
234
- this.registrationValid = [...this.registeredNames].every((name) => {
235
- const actual = available.get(name);
236
- const expected = this.definitions.get(name);
237
- return actual !== undefined && expected !== undefined &&
238
- actual.description === expected.description &&
239
- actual.parameters === expected.parameters &&
240
- actual.promptGuidelines === expected.promptGuidelines;
241
- });
264
+ const api = this.pi as ExtensionAPI & { getAllTools?: () => PublishedTool[] };
265
+ if (typeof api.getAllTools !== "function") return "conflict";
266
+ available = api.getAllTools();
242
267
  } catch {
243
- this.registrationValid = false;
268
+ // Stale or unbound runtime: retry on the next sync.
269
+ return "unverified";
270
+ }
271
+ const byName = new Map(available.map((tool) => [tool.name, tool]));
272
+ let missing = false;
273
+ let conflict = false;
274
+ for (const name of this.registeredNames) {
275
+ const actual = byName.get(name);
276
+ const expected = this.definitions.get(name);
277
+ if (actual === undefined || expected === undefined) {
278
+ missing = true;
279
+ continue;
280
+ }
281
+ if (!isOurTool(actual, expected)) conflict = true;
244
282
  }
245
- return this.registrationValid;
283
+ if (conflict) return "conflict";
284
+ if (missing) return "unverified";
285
+ this.verifiedOnce = true;
286
+ return "verified";
246
287
  }
247
288
 
248
- sync(active: boolean): boolean {
249
- if (!this.verifyRegistration()) return false;
289
+ sync(active: boolean): ContextToolSyncResult {
290
+ const registrationState = this.checkRegistration();
291
+ if (registrationState !== "verified") return { synced: false, registrationState };
250
292
  const api = this.pi as ExtensionAPI & { getActiveTools?: () => string[]; setActiveTools?: (names: string[]) => void };
251
- if (typeof api.getActiveTools !== "function" || typeof api.setActiveTools !== "function") return false;
293
+ if (typeof api.getActiveTools !== "function" || typeof api.setActiveTools !== "function") {
294
+ return { synced: false, registrationState };
295
+ }
252
296
  try {
253
297
  const current = api.getActiveTools();
254
298
  // Pi may activate a newly registered tool before the first sync. Once
@@ -258,16 +302,17 @@ export class ContextManagementToolController {
258
302
  ? [...current, ...[...this.registeredNames].filter((name) => !current.includes(name))]
259
303
  : current.filter((name) => !this.registeredNames.has(name));
260
304
  if (next.length !== current.length) api.setActiveTools(next);
261
- return true;
305
+ return { synced: true, registrationState };
262
306
  } catch {
263
- return false;
307
+ return { synced: false, registrationState };
264
308
  }
265
309
  }
266
310
 
267
311
  reset(): void {
268
312
  this.sync(false);
269
313
  }
270
- get isRegistered(): boolean { return this.registrationValid; }
314
+ get isRegistered(): boolean { return this.checkRegistration() === "verified"; }
315
+ get registrationState(): ContextToolRegistrationState { return this.checkRegistration(); }
271
316
  }
272
317
 
273
318
  export function registerContextManagementTools(
@@ -17,7 +17,10 @@ import {
17
17
  import { routeContextNamespaceToolMessage } from "./context-management/namespace-tools";
18
18
  import { loadHistoryNotesThreadHint } from "./context-management/history-notes";
19
19
  import { CodexContextWindowManager } from "./context-management/window-manager";
20
- import { registerContextManagementTools } from "./context-management/tools";
20
+ import {
21
+ registerContextManagementTools,
22
+ type ContextToolRegistrationState,
23
+ } from "./context-management/tools";
21
24
  import { writeDebugArtifact, writeReplayFailureArtifact } from "./debug";
22
25
  import {
23
26
  COMPACTION_CHECKPOINT_PROVENANCE_UNAVAILABLE,
@@ -88,6 +91,13 @@ type RemoteContextActive = (
88
91
  model?: ExtensionContext["model"],
89
92
  ) => Promise<boolean>;
90
93
 
94
+ type ContextToolSyncOutcome = {
95
+ eligible: boolean;
96
+ toolsSynced: boolean;
97
+ registrationState: ContextToolRegistrationState;
98
+ windowInitialized: boolean;
99
+ };
100
+
91
101
  type ResponsesCompactOutcome =
92
102
  | { outcome: "success"; compaction: CompactionResult<NativeCompactionDetails> }
93
103
  | { outcome: "aborted" }
@@ -301,6 +311,7 @@ async function runResponsesNativeCompact(
301
311
  ...serializeMessagesToResponsesInput(
302
312
  compactor.currentModel,
303
313
  [...projection.messages.slice(summaryIndex + 1)],
314
+ { firstSystemMessageIsUpdate: true },
304
315
  ),
305
316
  ];
306
317
  request = {
@@ -1014,28 +1025,60 @@ export default function registerCompactionExtension(
1014
1025
  piContextHookPatch: overrides.piContextHookPatch ?? piContextHookPatch,
1015
1026
  };
1016
1027
  let tools!: ReturnType<typeof registerContextManagementTools>;
1028
+ let contextWindowReady = false;
1029
+ const isContextRuntimeActive = async (
1030
+ ctx: ExtensionContext,
1031
+ config: CompactionConfig,
1032
+ model = ctx.model,
1033
+ ): Promise<boolean> =>
1034
+ contextWindowReady && tools.isRegistered && await isRemoteContextActive(ctx, config, model);
1017
1035
  tools = registerContextManagementTools(
1018
1036
  pi,
1019
1037
  contextWindows,
1020
1038
  async (ctx) => {
1021
1039
  const config = dependencies.loadConfig().config.compaction;
1022
- return tools.isRegistered && await isRemoteContextActive(ctx, config);
1040
+ return isContextRuntimeActive(ctx, config);
1023
1041
  },
1024
1042
  () => dependencies.loadConfig().config.compaction.gatewayContextModels,
1025
1043
  );
1026
- const remoteContextActive: RemoteContextActive = async (ctx, config, model = ctx.model) =>
1027
- tools.isRegistered && await isRemoteContextActive(ctx, config, model);
1028
- const syncTools = async (ctx: ExtensionContext, model = ctx.model): Promise<boolean> => {
1044
+ const remoteContextActive: RemoteContextActive = isContextRuntimeActive;
1045
+ const syncTools = async (
1046
+ ctx: ExtensionContext,
1047
+ model = ctx.model,
1048
+ options: { notifyWindowFailure?: boolean } = {},
1049
+ ): Promise<ContextToolSyncOutcome> => {
1050
+ // Until this call proves otherwise, do not let a previous session/window
1051
+ // identity make a failed activation look usable.
1052
+ contextWindowReady = false;
1029
1053
  const config = dependencies.loadConfig().config.compaction;
1030
- const active = await isRemoteContextActive(ctx, config, model);
1031
- // sync() reports whether the tool-set update succeeded; an inactive model
1032
- // syncs fine and returns true. The activation decision must use `active`
1033
- // itself, or non-covered models would receive a window boundary.
1034
- const synced = tools.sync(active);
1035
- return active && synced;
1054
+ const eligible = await isRemoteContextActive(ctx, config, model);
1055
+ const toolSync = tools.sync(eligible);
1056
+ const outcome: ContextToolSyncOutcome = {
1057
+ eligible,
1058
+ toolsSynced: toolSync.synced,
1059
+ registrationState: toolSync.registrationState,
1060
+ windowInitialized: false,
1061
+ };
1062
+ if (!eligible || !toolSync.synced) return outcome;
1063
+ // Activation can succeed after `session_start` missed it, because Pi 0.86 may
1064
+ // reject the registration read while a session replacement is still binding.
1065
+ // The window lifecycle has to open on that later activation too, or requests
1066
+ // carry no window metadata and the backend never ingests the turns.
1067
+ try {
1068
+ contextWindows.ensureInitialized(pi, ctx, true);
1069
+ contextWindowReady = true;
1070
+ return { ...outcome, windowInitialized: true };
1071
+ } catch {
1072
+ // Do not leave tools exposed while the request path has no valid window
1073
+ // identity. The next lifecycle hook may retry once Pi is usable again.
1074
+ tools.sync(false);
1075
+ if (options.notifyWindowFailure) notifyRemoteContextFailure(ctx, "malformed-window-state");
1076
+ return outcome;
1077
+ }
1036
1078
  };
1037
1079
  pi.on("session_start", async (_event, ctx) => {
1038
- const active = await syncTools(ctx);
1080
+ const syncOutcome = await syncTools(ctx, ctx.model, { notifyWindowFailure: true });
1081
+ const active = syncOutcome.eligible && syncOutcome.toolsSynced && syncOutcome.windowInitialized;
1039
1082
  const { config: toolkitConfig, source, warnings } = dependencies.loadConfig();
1040
1083
  const config = toolkitConfig.compaction;
1041
1084
  if (!config.enabled) return;
@@ -1043,16 +1086,16 @@ export default function registerCompactionExtension(
1043
1086
  let activationReason: string | undefined;
1044
1087
  // Only models the built-in Remote Context coverage targets may activate or
1045
1088
  // warn; everything else silently runs Pi's normal compaction path.
1046
- if (config.contextManagement === "remote" && isCodexContextModel(ctx.model, config)) {
1047
- if (active) {
1048
- try {
1049
- contextWindows.ensureInitialized(pi, ctx, true);
1050
- } catch {
1051
- notifyRemoteContextFailure(ctx, "malformed-window-state");
1052
- }
1053
- } else if (!tools.isRegistered) {
1089
+ if (config.contextManagement === "remote" && isCodexContextModel(ctx.model, config) && !active) {
1090
+ if (syncOutcome.registrationState === "conflict") {
1054
1091
  activationReason = "tool-name-conflict";
1055
1092
  notifyRemoteContextFailure(ctx, activationReason);
1093
+ } else if (syncOutcome.registrationState === "unverified" || !syncOutcome.toolsSynced) {
1094
+ // Transient: the runtime has not published or accepted our tools yet. A
1095
+ // later before_agent_start re-verifies, so do not report provider failure.
1096
+ activationReason = "tool-registration-pending";
1097
+ } else if (syncOutcome.eligible && !syncOutcome.windowInitialized) {
1098
+ activationReason = "malformed-window-state";
1056
1099
  } else {
1057
1100
  const remoteResolution = await resolveCodexContextProvider(ctx, ctx.model, config.gatewayContextModels);
1058
1101
  activationReason = remoteResolution.ok ? "codex-context-unavailable" : remoteResolution.reason;
@@ -1098,25 +1141,20 @@ export default function registerCompactionExtension(
1098
1141
  pi.on("session_before_compact", (event, ctx) => handleSessionBeforeCompact(event, ctx, dependencies, remoteContextActive));
1099
1142
  pi.on("session_compact", (event, _ctx) => contextWindows.recordCompaction(event.compactionEntry.details));
1100
1143
  pi.on("session_shutdown", () => {
1144
+ contextWindowReady = false;
1101
1145
  contextWindows.reset();
1102
1146
  tools.reset();
1103
1147
  });
1104
1148
  pi.on("model_select", async (event, ctx) => {
1105
- const active = await syncTools(ctx, event.model);
1106
1149
  // Switching into a covered model mid-session must open the window
1107
1150
  // lifecycle immediately: without an identity the request rewrite skips
1108
1151
  // window metadata, the backend never ingests those turns, and the first
1109
1152
  // new_context would trim pre-switch history that no history can recover.
1110
- // ensureInitialized is idempotent when a window already exists.
1111
- if (!active) return;
1112
- try {
1113
- contextWindows.ensureInitialized(pi, ctx, true);
1114
- } catch {
1115
- notifyRemoteContextFailure(ctx, "malformed-window-state");
1116
- }
1153
+ // syncTools() activates and initializes the window when the model is covered.
1154
+ await syncTools(ctx, event.model, { notifyWindowFailure: true });
1117
1155
  });
1118
1156
  pi.on("before_agent_start", async (_event, ctx) => {
1119
- await syncTools(ctx);
1157
+ await syncTools(ctx, ctx.model, { notifyWindowFailure: true });
1120
1158
  });
1121
1159
  pi.on("before_provider_request", (event, ctx) => handleBeforeProviderRequest(event, ctx, dependencies.loadConfig, contextWindows, remoteContextActive));
1122
1160
  pi.on("before_provider_headers", async (event, ctx) => {
@@ -337,7 +337,11 @@ export function removeNativeCompactionRetainedMessages(args: {
337
337
  }
338
338
  const firstKept = findEntryIndexByIdBeforeBoundary(args.branchEntries, args.compactionEntry.firstKeptEntryId, boundary);
339
339
  if (firstKept === undefined) return { ok: false, reason: "first-kept-entry-not-found" };
340
- const summary = structuredClone(sessionEntryToContextMessages(args.compactionEntry)[0]);
340
+ const summaryMessage = sessionEntryToContextMessages(args.compactionEntry).find(
341
+ (message) => message.role === "compactionSummary",
342
+ );
343
+ if (!summaryMessage) return { ok: false, reason: "compaction-summary-not-found" };
344
+ const summary = structuredClone(summaryMessage);
341
345
  const summaryIndex = args.messages.findIndex((message) => areEquivalentValues(message, summary));
342
346
  if (summaryIndex < 0) return { ok: false, reason: "compaction-summary-not-found" };
343
347
  const retained = args.branchEntries
@@ -349,7 +353,13 @@ export function removeNativeCompactionRetainedMessages(args: {
349
353
  // removed when present, while a missing or rewritten copy is left alone.
350
354
  // Required user/assistant/tool messages keep the ordered, fail-closed
351
355
  // matching contract.
352
- const requiredRetained = retained.filter((message) => message.role !== "custom");
356
+ // Pi 0.86 promotes transcript system messages to the leading prompt when it
357
+ // rebuilds context after compaction. They can therefore appear before the
358
+ // compaction summary even when their session entry lies inside the retained
359
+ // span; keep them in place instead of treating that relocation as a mismatch.
360
+ const requiredRetained = retained.filter(
361
+ (message) => message.role !== "custom" && (message.role as string) !== "system",
362
+ );
353
363
  const optionalRetained = retained.filter((message) => message.role === "custom");
354
364
  const removed = new Set<number>();
355
365
  let cursor = summaryIndex + 1;
@@ -388,7 +398,9 @@ export function serializeLiveTailToResponsesInput<TApi extends Api>(args: {
388
398
  model: Model<TApi>;
389
399
  entries: readonly SessionEntry[];
390
400
  }): ResponsesInputItem[] {
391
- return serializeMessagesToResponsesInput(args.model, collectReplayMessages(args.entries));
401
+ return serializeMessagesToResponsesInput(args.model, collectReplayMessages(args.entries), {
402
+ firstSystemMessageIsUpdate: true,
403
+ });
392
404
  }
393
405
 
394
406
  function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
package/src/serializer.ts CHANGED
@@ -89,6 +89,21 @@ export type ResponsesFunctionCallOutputItem = {
89
89
 
90
90
  export type ResponsesReasoningItem = Record<string, unknown>;
91
91
 
92
+ /**
93
+ * Pi 0.86 adds `SystemMessage` to the public `Message` union; 0.85.1 has no system
94
+ * variant and does not export that type name. Use the public shape when the installed
95
+ * Pi declares it and a structural fallback for the fields this serializer consumes
96
+ * otherwise, so one source file type-checks against both supported versions.
97
+ */
98
+ type ResponsesSystemMessage = Extract<Message, { role: "system" }> extends never
99
+ ? {
100
+ role: "system";
101
+ content: string | TextContent[];
102
+ sections?: Record<string, string | null>;
103
+ timestamp: number;
104
+ }
105
+ : Extract<Message, { role: "system" }>;
106
+
92
107
  export type ResponsesInputItem =
93
108
  | ResponsesInputMessageItem
94
109
  | ResponsesAssistantOutputItem
@@ -106,6 +121,8 @@ export type NativeCompactionRequestBody = CompactionRequestExtras & {
106
121
  export type SerializeResponsesMessagesOptions = {
107
122
  instructions?: string;
108
123
  includeInstructionsInInput?: boolean;
124
+ /** Treat a system message at the start of a sliced post-compaction tail as an update. */
125
+ firstSystemMessageIsUpdate?: boolean;
109
126
  };
110
127
 
111
128
  export type ResponsesParityReport = {
@@ -126,6 +143,13 @@ function sanitizeSurrogates(text: string): string {
126
143
  return text.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "");
127
144
  }
128
145
 
146
+ function instructionRole<TApi extends Api>(model: Model<TApi>): "developer" | "system" {
147
+ // Pi's public Model compat union is broader than the OpenAI Responses compat
148
+ // shape, while the runtime field is shared by both supported Pi versions.
149
+ const compat = model.compat as { supportsDeveloperRole?: boolean } | undefined;
150
+ return model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system";
151
+ }
152
+
129
153
  export function collectCompactionWindowMessages(preparation: CompactionPreparation): AgentMessage[] {
130
154
  return [...preparation.messagesToSummarize, ...preparation.turnPrefixMessages];
131
155
  }
@@ -159,19 +183,38 @@ export function serializeMessagesToResponsesInput<TApi extends Api>(
159
183
  messages: AgentMessage[],
160
184
  options: SerializeResponsesMessagesOptions = {},
161
185
  ): ResponsesInputItem[] {
162
- const llmMessages = convertToLlm(messages);
163
- const transformedMessages = transformMessagesForResponses(llmMessages);
186
+ return serializeLlmMessagesToResponsesInput(model, convertToLlm(messages), options);
187
+ }
188
+
189
+ /**
190
+ * Serialize Pi's normalized LLM message union without running `convertToLlm` again.
191
+ * This keeps the system-message boundary directly testable while preserving the public
192
+ * AgentMessage-facing entry point above.
193
+ */
194
+ export function serializeLlmMessagesToResponsesInput<TApi extends Api>(
195
+ model: Model<TApi>,
196
+ messages: Message[],
197
+ options: SerializeResponsesMessagesOptions = {},
198
+ ): ResponsesInputItem[] {
199
+ const transformedMessages = transformMessagesForResponses(messages);
164
200
  const input: ResponsesInputItem[] = [];
165
201
 
166
202
  if (options.includeInstructionsInInput && options.instructions) {
167
203
  input.push({
168
- role: model.reasoning ? "developer" : "system",
204
+ role: instructionRole(model),
169
205
  content: sanitizeSurrogates(options.instructions),
170
206
  });
171
207
  }
172
208
 
173
209
  let messageIndex = 0;
210
+ let sourceIndex = 0;
174
211
  for (const message of transformedMessages) {
212
+ // Pi 0.85's Message union has no system member, so compare through the
213
+ // public role string while keeping the 0.86 discriminant runtime-compatible.
214
+ const isFirstMessage = sourceIndex++ === 0;
215
+ const isLeadingSystemMessage =
216
+ !options.firstSystemMessageIsUpdate && isFirstMessage && (message.role as string) === "system";
217
+
175
218
  if (message.role === "user") {
176
219
  const item = serializeUserMessage(message, model);
177
220
  if (item) {
@@ -190,8 +233,21 @@ export function serializeMessagesToResponsesInput<TApi extends Api>(
190
233
  continue;
191
234
  }
192
235
 
193
- input.push(serializeToolResultMessage(message, model));
194
- messageIndex++;
236
+ if (message.role === "toolResult") {
237
+ input.push(serializeToolResultMessage(message, model));
238
+ messageIndex++;
239
+ continue;
240
+ }
241
+
242
+ // In Pi 0.85.1 this branch narrows to `never`; in Pi 0.86 it is a SystemMessage.
243
+ // Handle it explicitly instead of letting a new message variant become tool output.
244
+ const systemItem = serializeSystemMessage(message, model, isLeadingSystemMessage);
245
+ if (systemItem) {
246
+ input.push(systemItem);
247
+ }
248
+ if (!isLeadingSystemMessage) {
249
+ messageIndex++;
250
+ }
195
251
  }
196
252
 
197
253
  return input;
@@ -446,6 +502,72 @@ function normalizeUserContent(content: UserMessage["content"]): Array<TextConten
446
502
  return typeof content === "string" ? [{ type: "text", text: content }] : content;
447
503
  }
448
504
 
505
+ /**
506
+ * Serialize one normalized Pi system message as a Responses input message.
507
+ *
508
+ * The role follows the same reasoning-model convention as `instructions`: reasoning
509
+ * models use `developer`, everything else keeps `system`. Leading messages include
510
+ * their complete section state; later messages use Pi's update framing so section
511
+ * additions/removals are not mistaken for a new complete prompt. An empty system
512
+ * message produces no item, matching Pi.
513
+ */
514
+ function serializeSystemMessage<TApi extends Api>(
515
+ message: ResponsesSystemMessage,
516
+ model: Model<TApi>,
517
+ isLeadingSystemMessage: boolean,
518
+ ): ResponsesInputMessageItem | undefined {
519
+ const text = sanitizeSurrogates(
520
+ isLeadingSystemMessage ? getSystemMessageText(message) : renderSystemMessageUpdate(message),
521
+ );
522
+ if (text.length === 0) {
523
+ return undefined;
524
+ }
525
+
526
+ return {
527
+ role: instructionRole(model),
528
+ content: text,
529
+ };
530
+ }
531
+
532
+ /** Mirrors Pi's public getSystemMessageText() without importing a 0.86-only helper. */
533
+ function getSystemMessageText(message: ResponsesSystemMessage): string {
534
+ const parts = [contentText(message.content)];
535
+ for (const text of Object.values(message.sections ?? {})) {
536
+ if (text !== null) {
537
+ parts.push(text);
538
+ }
539
+ }
540
+ return parts.filter((part) => part.length > 0).join("\n\n");
541
+ }
542
+
543
+ /** Mirrors Pi's public renderSystemMessageUpdate() for later transcript updates. */
544
+ function renderSystemMessageUpdate(message: ResponsesSystemMessage): string {
545
+ const parts: string[] = [];
546
+ const text = contentText(message.content);
547
+ if (text.length > 0) {
548
+ parts.push(text);
549
+ }
550
+ for (const [name, value] of Object.entries(message.sections ?? {})) {
551
+ parts.push(
552
+ value === null
553
+ ? `Removed system prompt section "${name}".`
554
+ : `Updated system prompt section "${name}":\n\n${value}`,
555
+ );
556
+ }
557
+ return parts.join("\n\n");
558
+ }
559
+
560
+ function contentText(content: ResponsesSystemMessage["content"]): string {
561
+ if (typeof content === "string") {
562
+ return content;
563
+ }
564
+
565
+ return content
566
+ .filter((item): item is TextContent => item.type === "text")
567
+ .map((item) => item.text)
568
+ .join("\n");
569
+ }
570
+
449
571
  function parseReasoningItem(block: ThinkingContent): ResponsesReasoningItem | undefined {
450
572
  if (!block.thinkingSignature) {
451
573
  return undefined;
@@ -12,10 +12,6 @@
12
12
  "typeRoots": ["./node_modules/@types"]
13
13
  },
14
14
  "include": [
15
- "extensions/compaction.ts",
16
- "extensions/web-search.ts",
17
- "extensions/image-generation.ts",
18
- "extensions/auto-mode.ts",
19
15
  "src/compact-client.ts",
20
16
  "src/compaction-projection.ts",
21
17
  "src/pi-context-hook.ts",
@@ -1 +0,0 @@
1
- export { default } from "../src/auto-mode/extension.ts";
@@ -1 +0,0 @@
1
- export { default } from "../src/codex-astra/extension.ts";
@@ -1 +0,0 @@
1
- export { default } from "../src/extension-runtime.ts";
@@ -1 +0,0 @@
1
- export { default } from "../src/image-generation/extension.ts";
@@ -1 +0,0 @@
1
- export { default } from "../src/web-search/extension.ts";