pi-openai-toolkit 0.22.2 → 0.23.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.
package/README.md CHANGED
@@ -52,11 +52,13 @@ Set `defaults.context.mode` or an individual model's `context.mode`:
52
52
 
53
53
  **Remote compaction** accepts `openai-responses`, `openai-codex-responses`, and `azure-openai-responses`. It tries V2 first. On the official OpenAI API-key route, a qualifying protocol failure can trigger one standalone `/v1/responses/compact` request; its complete encrypted replacement window is preserved for replay. OpenAI OAuth, native Codex, Azure, and gateways keep their existing V2 route. OpenAI subscription-sharing OAuth and native Codex login are separate grants, not interchangeable credentials. Backend/model access is still required.
54
54
 
55
- Set `context.remoteCompaction.model` to use a separate producer on the same effective base URL without changing the active model. Exhausted remote attempts warn before continuing with a native text summary. `nativeFallback.enabled: false` disables only Toolkit's configured-model tier, not Pi's default summary. Aborts, incompatible provenance, and edits to sealed history still cancel rather than bypassing checkpoint safety. See [context settings](docs/configuration.md#context) and [checkpoint rules](docs/internals.md#remote-compaction-v2-wire-contract).
55
+ Set `context.remoteCompaction.model` to use a separate producer on the same effective base URL without changing the active model. For ordinary remote compaction, exhausted remote attempts warn before continuing with a native text summary. `nativeFallback.enabled: false` disables only Toolkit's configured-model tier, not Pi's default summary. Aborts, incompatible provenance, and edits to sealed history still cancel rather than bypassing checkpoint safety. See [context settings](docs/configuration.md#context) and [checkpoint rules](docs/internals.md#remote-compaction-v2-wire-contract).
56
56
 
57
57
  **Remote windows** require the native `openai-codex` provider with `openai-codex-responses`, or an `openai-responses` gateway implementing the protocol. Gateways require `compatibility.transport: "codex-gateway"` on the exact model entry. This setting does not add the capability to an ordinary compatible endpoint.
58
58
 
59
- Change `mode` in the example above to `remote-windows`. Eligible sessions expose `new_context`, `get_context_remaining`, `history`, and `notes`. Manual `/compact [instructions]` starts a checkpoint handoff: write notes successfully, then switch windows. An existing effective window boundary survives model changes and switches to `remote-compaction` or `pi`: only the current local window is used, and retired Remote Context history is not sent to the model or a compaction producer. A no-summary local boundary preserves the persisted Remote Window / Context Session, so switching back to a supported model reuses the same window instead of opening a duplicate one. The legacy `leaveManagedMode` setting is retained for config compatibility but no longer triggers bulk close-out compaction.
59
+ Change `mode` in the example above to `remote-windows`. Eligible sessions expose `new_context`, `get_context_remaining`, `history`, and `notes`. Manual `/compact [instructions]` remotely compresses the current window without switching the active model or opening another window. After the checkpoint is saved, the original model continues; failure stops with the existing context preserved, without a text-summary fallback. It uses the same `context.remoteCompaction.model` setting; `null` keeps the current model.
60
+
61
+ An existing effective window boundary survives model changes and switches to `remote-compaction` or `pi`: only the current local window is used, and retired Remote Context history is not sent to the model or a compaction producer. A no-summary local boundary preserves the persisted Remote Window / Context Session, so switching back to a supported model reuses the same window instead of opening a duplicate one. That boundary is written once: later compactions under a non-window model continue through the ordinary producer, native-fallback or Pi-default chain instead of being cancelled. The legacy `leaveManagedMode` setting is retained for config compatibility but no longer triggers bulk close-out compaction.
60
62
 
61
63
  Remote windows use alpha endpoints that may change upstream. When switching models, the remote window remains persisted and reusable; see the [window lifecycle](docs/internals.md#rollover-lifecycle).
62
64
 
package/README.zh.md CHANGED
@@ -52,11 +52,13 @@ Toolkit 只读取一个全局配置文件,即使扩展安装在项目内也一
52
52
 
53
53
  **远程压缩**支持 `openai-responses`、`openai-codex-responses` 和 `azure-openai-responses`,先尝试 V2。官方 OpenAI API-key 路由遇到符合条件的协议失败时,可再尝试一次独立的 `/v1/responses/compact`,并完整保存返回的加密替换窗口用于重放。OpenAI OAuth、原生 Codex、Azure 和网关保持原有 V2 路由。OpenAI subscription-sharing OAuth 与原生 Codex 登录是两种独立授权,凭据不能互换;模型和后端仍须具备相应权限。
54
54
 
55
- 可用 `context.remoteCompaction.model` 指定同一有效 base URL 下的独立压缩模型,不改变当前模型。远程尝试全部失败后,会明确提示并继续普通文本摘要。`nativeFallback.enabled: false` 只关闭 Toolkit 的指定模型回退层,不关闭 Pi 默认摘要。取消操作、来源不匹配或已封存历史被修改时,仍会取消,不能绕过检查点安全检查。详见[上下文配置](docs/configuration.md#context)和[检查点规则](docs/internals.md#remote-compaction-v2-wire-contract)。
55
+ 可用 `context.remoteCompaction.model` 指定同一有效 base URL 下的独立压缩模型,不改变当前模型。普通远程压缩的远程尝试全部失败后,会明确提示并继续文本摘要。`nativeFallback.enabled: false` 只关闭 Toolkit 的指定模型回退层,不关闭 Pi 默认摘要。取消操作、来源不匹配或已封存历史被修改时,仍会取消,不能绕过检查点安全检查。详见[上下文配置](docs/configuration.md#context)和[检查点规则](docs/internals.md#remote-compaction-v2-wire-contract)。
56
56
 
57
57
  **远程窗口**需要原生 `openai-codex` 提供商的 `openai-codex-responses` API,或支持相应协议的 `openai-responses` 网关。网关必须在精确模型下设置 `compatibility.transport: "codex-gateway"`;这一设置不会让普通兼容端点自动获得该能力。
58
58
 
59
- 将上例的 `mode` 改为 `remote-windows` 后,符合条件的会话会提供 `new_context`、`get_context_remaining`、`history` 和 `notes`。手动 `/compact [instructions]` 会发起检查点交接:先成功写入 notes,再切换窗口。切换模型或改用 `remote-compaction`、`pi` 模式后,已有的有效窗口边界仍然生效:只使用当前本地窗口,旧窗口历史不会发送给新模型或 compaction 模型。扩展会用一个不生成摘要的本地边界保留已持久化的 Remote Window / Context Session;切回支持窗口的模型时会复用同一个窗口,不会重复创建。旧配置中的 `leaveManagedMode` 仍会解析,但不再触发批量 close-out 压缩。
59
+ 将上例的 `mode` 改为 `remote-windows` 后,符合条件的会话会提供 `new_context`、`get_context_remaining`、`history` 和 `notes`。手动 `/compact [instructions]` 会远程压缩当前窗口,不切换当前模型,也不打开新窗口。检查点保存成功后由原模型继续;失败则停止并保留现有上下文,不回退到文本摘要。它沿用 `context.remoteCompaction.model` 设置;`null` 仍使用当前模型。
60
+
61
+ 切换模型或改用 `remote-compaction`、`pi` 模式后,已有的有效窗口边界仍然生效:只使用当前本地窗口,旧窗口历史不会发送给新模型或 compaction 模型。扩展会用一个不生成摘要的本地边界保留已持久化的 Remote Window / Context Session;切回支持窗口的模型时会复用同一个窗口,不会重复创建。该边界只写一次:之后在非窗口模型下的压缩会继续走普通 producer、native fallback 或 Pi 默认链路,而不再被取消。旧配置中的 `leaveManagedMode` 仍会解析,但不再触发批量 close-out 压缩。
60
62
 
61
63
  远程窗口依赖 alpha 接口,上游可能随时调整。切换模型时远程窗口状态仍会持久化并可复用,详见[窗口生命周期](docs/internals.md#rollover-lifecycle)。
62
64
 
@@ -57,8 +57,8 @@ Place `context` under `defaults` or an exact `models` entry.
57
57
  | Relative field | Default | Contract |
58
58
  | --- | --- | --- |
59
59
  | `mode` | `"remote-compaction"` | `"pi"` delegates ordinary context management to Pi; `"remote-compaction"` uses the eligible Responses checkpoint path; `"remote-windows"` selects Codex windows where supported. |
60
- | `remoteCompaction.model` | `null` | Exact producer reference; `null` uses the active model. In `remote-compaction`, it selects the synthetic checkpoint producer on the same effective base URL. In active `remote-windows`, manual `/compact` temporarily selects it for notes checkpoint duty on the same backend/account. |
61
- | `remoteCompaction.inputSource` | `"legacy"` | `"legacy"` preserves session/raw-branch input; `"pi-context-hook"` opts into Pi's ordered context projection. Checkpoint provenance must match. |
60
+ | `remoteCompaction.model` | `null` | Exact synthetic producer reference for `remote-compaction` and active `remote-windows` manual `/compact`, on the same effective base URL. `null` uses the active model; a separate producer never changes the active model. |
61
+ | `remoteCompaction.inputSource` | `"legacy"` | `"legacy"` keeps the existing session/branch input path without request-time context hooks; `"pi-context-hook"` opts into Pi's ordered context projection. Active Remote manual compression uses the canonical effective current window in either mode. Checkpoint provenance must match. |
62
62
  | `remoteCompaction.allowContinuityBreak` | `false` | Allow restarting from Pi context after a foreign compaction entry. It does not make malformed opaque checkpoints replayable. |
63
63
  | `remoteCompaction.apis` | `["openai-responses", "openai-codex-responses", "azure-openai-responses"]` | May narrow this set; `[]` permits no remote-compaction API. Azure uses Pi's resolved `AZURE_OPENAI_*` environment values for the `/openai/v1` Responses path, deployment mapping, and `api-version`; unsupported entries are errors. |
64
64
  | `nativeFallback.enabled` | `true` | Enable the existing native-method fallback tier. |
@@ -71,13 +71,13 @@ Ordinary remote compaction tries V2 first. One automatic standalone `/v1/respons
71
71
 
72
72
  Standalone success preserves the full canonical output window, including retained items, rather than treating retained assistant text as a summary. When actual remote attempts are exhausted, Toolkit warns that no encrypted checkpoint was produced and starts the existing native text-summary chain: the configured remote producer after a remote attempt, or `nativeFallback.model` when remote compaction is unavailable. `nativeFallback.enabled: false` disables only the configured-model tier; disabled/unset/same-model selections still permit Pi's default summary. Explicit summary-model failures and checkpoint-integrity failures cancel. OpenAI subscription-sharing OAuth can reject compaction; separately authenticating native Codex is a different route, not a way to reuse that grant. See the [wire contract](internals.md#remote-compaction-v2-wire-contract).
73
73
 
74
- Native Codex eligibility derives from the actual Pi provider/API. Gateways additionally need the exact transport opt-in below. An existing effective Remote Window boundary survives model and context-mode changes, including `remote-compaction`, `pi`, and legacy disabled/off settings. A non-window consumer receives only the current local window; retired Remote Context history is excluded from live requests and compaction producers. This local safety check does not activate Remote tools or protocol support. A no-summary boundary preserves the marker for later reuse. A configured window model whose activation fails does not silently receive a summary fallback. Normal `new_context` keeps its persisted-notes, duplicate and cooldown gates. Synthetic Remote V2 producer/consumer identity and checkpoint provenance are unchanged. See [Toolkit internals](internals.md).
74
+ Native Codex eligibility derives from the actual Pi provider/API. Gateways additionally need the exact transport opt-in below. An existing effective Remote Window boundary survives model and context-mode changes, including `remote-compaction`, `pi`, and legacy disabled/off settings. A non-window consumer receives only the current local window; retired Remote Context history is excluded from live requests and compaction producers. This local safety check does not activate Remote tools or protocol support. A no-summary boundary preserves the marker for later reuse and is written once; later compaction under a non-window model continues through the ordinary producer/native/Pi-default chain. A configured window model whose activation fails does not silently receive a summary fallback. Normal `new_context` keeps its persisted-notes, duplicate and cooldown gates. Synthetic Remote V2 producer/consumer identity and checkpoint provenance are unchanged. See [Toolkit internals](internals.md).
75
75
 
76
- In active `remote-windows`, `/compact [instructions]` starts checkpoint duty rather than producing a summary. The selected model must authenticate to the same native backend/account or gateway credential/affinity domain, fit the projected window plus its output reserve, and retain the required permitted context tools. Invalid or unsuitable explicit targets are refused without switching to a fallback model. The entire handoff uses the initiating context-policy snapshot; no config or global Pi defaults are rewritten.
76
+ In active `remote-windows`, `/compact [instructions]` produces a Remote V2 encrypted checkpoint for only the effective current window. It excludes retired windows and checkpoints belonging to earlier boundaries, preserves the current prompt/tools and context edits, and uses the initiating policy snapshot. It does not select another active model, run a notes-writing agent, require a new notes checkpoint, or call `new_context`. The producer still needs the same effective base URL and supported runtime. Leaving `remoteCompaction.model` null does not move long-input compression to a cheaper model.
77
77
 
78
- Only a new persisted successful notes write/append after the handoff and the latest delivered user message authorizes its rollover. After the exact target marker is durable, Toolkit restores the original model/thinking before new-window inference and asks it to read the receipt first. Failure or cancellation restores an owned selection; a later user selection takes priority. Failed restoration blocks further handoff requests until selection/auth is repaired. Reload can recover selection, but does not automatically restart inference.
78
+ After the exact new checkpoint is persisted and Pi has finished manual compaction, the unchanged original model automatically continues in the same session/window. New user work, model/thinking selection, navigation or cancellation supersedes pending continuation; host queues are not cleared or reordered. If safe continuation cannot be established, the saved checkpoint remains available for manual resumption. Reload never restarts inference. Ordinary `notes`, `history` and `new_context` remain usable.
79
79
 
80
- The SDK native compact call reports its intentional cancellation; the visible handoff is a separate agent operation. Pi's pre-hook “nothing to compact” and “already compacted” outcomes remain. Automatic threshold/overflow compaction and internal pending-trim maintenance do not initiate checkpoint duty. `nativeFallback.model` keeps its existing summary-model meaning.
80
+ Any failure in this active-manual path stops and preserves the existing context. It does not try standalone compaction, native text summary or Pi's active-model fallback, regardless of `nativeFallback.enabled`. Successful manual compact now returns a real compaction result; Pi's pre-hook “nothing to compact” and “already compacted” outcomes remain. Automatic threshold/overflow and internal pending-trim maintenance keep their existing behavior. Old persisted notes-handoff records remain readable for owned selection recovery only, without retrying inference. `nativeFallback.model` retains its existing meaning outside this active-manual scope.
81
81
 
82
82
  ---
83
83
 
package/docs/internals.md CHANGED
@@ -21,7 +21,7 @@ Developer-facing reference for [pi-openai-toolkit](../README.md). The README kee
21
21
 
22
22
  The single global document is loaded through [src/config.ts](../src/config.ts). [src/config/legacy.ts](../src/config/legacy.ts) owns unversioned compatibility; [src/config/v2.ts](../src/config/v2.ts) validates v2 scopes, resolves exact policy, and records leaf origins. [src/config/policy.ts](../src/config/policy.ts) defines effective values independently of persisted session data. See the [configuration reference](configuration.md) for supported fields and migration limits.
23
23
 
24
- Feature entrypoints resolve once per public operation and pass that immutable snapshot through awaited helpers. A separately named compaction producer is resolved from the same decoded document for its own identity. Subsequent independent callbacks reload; a managed manual handoff retains its initiating context-policy snapshot until selection is restored. There is no general cross-event provider transaction, project lookup, singleton cache, or config state writer. Internal legacy-shaped engine adapters preserve compaction/search algorithms without exposing v2 parsing to consumers. Cross-feature gateway policy belongs to the resolver, not the context enable switch.
24
+ Feature entrypoints resolve once per public operation and pass that immutable snapshot through awaited helpers. A separately named compaction producer is resolved from the same decoded document for its own identity. Subsequent independent callbacks reload; active Remote manual compression retains its initiating context-policy snapshot through the owned operation, while legacy handoffs retain theirs through owned selection recovery. There is no general cross-event provider transaction, project lookup, singleton cache, or config state writer. Internal legacy-shaped engine adapters preserve compaction/search algorithms without exposing v2 parsing to consumers. Cross-feature gateway policy belongs to the resolver, not the context enable switch.
25
25
 
26
26
  Selected invalid policy blocks dependent operations; unrelated model errors remain reportable without replacing the selected model. Retired `autoMode` sections are ignored with a warning; they never create effective policy or invalidate other features. Image choices are checked before auth, uploads, or paid requests. The human `/toolkit-config` command inspects effective values, validation, or a migration candidate without action APIs or network access. Migration is preview-only; source bytes and unknown/dormant content stay untouched.
27
27
 
@@ -109,31 +109,31 @@ State invariants the lifecycle code must keep honest:
109
109
  - 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.
110
110
  - Pi's canonical session projection is the source of effective history. Toolkit may further restrict that projection, but never reconstruct omitted or summarized messages from the raw branch. Boundary resolution uses projected source entries and the active branch's latest compaction; an older marker retained inside a newer compaction does not authorize dropping that compaction's summary.
111
111
  - Window identity and cleanup eligibility are separate. A persisted `codex-context-window` message restores identity after reload/fork, but its historical `trimPreviousWindow` flag is not an outstanding command. A newer native summary or opaque checkpoint supersedes old cleanup without erasing window identity. Missing or edited boundary evidence is never repaired by loading raw history.
112
- - Maintenance compaction and local-consumer detachment use the same effective-boundary rules. Only a safe current boundary can produce a no-summary compaction; obsolete cleanup must not move the retained start backwards or replace a newer summary/checkpoint. Preparation alone changes no durable state, and cancellation, retry and branch navigation are resolved from current evidence rather than callback acknowledgments. User manual `/compact` still follows the managed handoff below.
113
- - An effective persisted window boundary belongs to the session, not the selected model or context mode. `context_with_system`, `before_provider_request`, and `session_before_compact` synchronize it before mode-specific exits, including `remote-compaction`, `pi`, and legacy disabled/off selections. A non-window consumer receives only the effective current context, with the current system/tool head preserved and internal markers removed; no Remote tools, metadata or affinity headers are activated by the local safety check. A pre-window opaque checkpoint cannot rehydrate retired history after local projection. When detachment is needed, the existing no-summary boundary runs before any configured, remote, native or Pi-default compaction producer. Once a later ordinary compaction owns effective context, the historical window does not suppress its summary or valid opaque replay. A genuinely new window can retire that checkpoint. Disabling opaque checkpoint generation through `responsesApis` does not disable required replay of an inherited checkpoint; an unsupported transport, authentication failure, or endpoint mismatch aborts the request instead of exposing its placeholder. The legacy `leaveManagedMode` setting remains compatible but does not start a bulk close-out from `model_select` or `agent_settled`.
112
+ - Maintenance compaction and local-consumer detachment use the same effective-boundary rules. Only a safe current boundary can produce a no-summary compaction; obsolete cleanup must not move the retained start backwards or replace a newer summary/checkpoint. Preparation alone changes no durable state, and cancellation, retry and branch navigation are resolved from current evidence rather than callback acknowledgments. A boundary already consumed by its own no-summary compaction is not a dead end: the scheduled-trim path still refuses a second boundary, but a local consumer falls through to the ordinary compaction chain, because Pi's canonical projection already starts at that boundary and retired Remote history is outside every producer's input. User manual `/compact` still follows the managed handoff below.
113
+ - An effective persisted window boundary belongs to the session, not the selected model or context mode. `context_with_system`, `before_provider_request`, and `session_before_compact` synchronize it before mode-specific exits, including `remote-compaction`, `pi`, and legacy disabled/off selections. A non-window consumer receives only the effective current context, with the current system/tool head preserved and internal markers removed; no Remote tools, metadata or affinity headers are activated by the local safety check. A pre-window opaque checkpoint cannot rehydrate retired history after local projection. When detachment is needed, the existing no-summary boundary runs before any configured, remote, native or Pi-default compaction producer. A boundary that a previous detach already persisted yields to that same producer chain instead of cancelling the operation. Once a later ordinary compaction owns effective context, the historical window does not suppress its summary or valid opaque replay. A genuinely new window can retire that checkpoint. Disabling opaque checkpoint generation through `responsesApis` does not disable required replay of an inherited checkpoint; an unsupported transport, authentication failure, or endpoint mismatch aborts the request instead of exposing its placeholder. The legacy `leaveManagedMode` setting remains compatible but does not start a bulk close-out from `model_select` or `agent_settled`.
114
114
  - Native text summarization delegates prompt construction, tool-result truncation, split-turn requests and output limits to Pi's `compact()`. Toolkit does not use character-count estimates to reject the configured summary model. If its resolution, authentication or summary generation fails, compaction is cancelled with the error and context is preserved; the active model is not an implicit retry target. Disabled/unset/same-model selections retain Pi's default path. Remote-window projection still excludes retired managed history before any producer call.
115
115
 
116
116
  ---
117
117
 
118
118
  ### Managed manual compact
119
119
 
120
- [src/context-management/manual-compact.ts](../src/context-management/manual-compact.ts) owns manual checkpoint duty. It uses public Pi lifecycle and selection APIs; it does not replace the built-in command or patch the scheduler. [src/context-management/window-manager.ts](../src/context-management/window-manager.ts) remains the sole owner of notes pairing, window identity, projection and trim.
120
+ Active Remote Window manual `/compact` uses the shared Remote V2 client, not a notes-writing agent. [src/context-management/manual-compact.ts](../src/context-management/manual-compact.ts) owns its narrow cancellation/continuation lifecycle; [src/context-management/window-manager.ts](../src/context-management/window-manager.ts) remains the owner of window identity, canonical projection, notes pairing and trim. The built-in command and Pi scheduler are unchanged.
121
121
 
122
- 1. An active managed `session_before_compact` with reason `manual` validates the source/target policies from one snapshot, actual permitted tools, remaining capacity and backend/account scope. It records the original model/thinking and cancels the native compact attempt. Internal window-bulk maintenance has a separate in-process owner even though Pi also labels it `manual`.
123
- 2. The matching `session_compact_failed` callback launches duty only after Pi is idle. Public `setModel` changes the session selection, and a visible `sendUserMessage` rebuilds the target's normal prompt/tools. Delivery is observed through `message_start`; return from the fire-and-forget send API is not proof. A delivery timeout restores an abandoned idle selection.
124
- 3. The existing `new_context` gate additionally requires a successful paired notes result after this operation and any later delivered user message. It records the exact target window before queueing a context-only marker, and returns a terminating result. Ordinary tools are blocked during checkpoint duty. Normal `new_context` outside this operation is unchanged.
125
- 4. A terminating result alone does not stop mixed batches or queued messages. At `turn_end`, Toolkit requests abort without awaiting idle; context/request guards also refuse an unsafe continuation. The standard Responses and native Codex transports must not send an already-aborted continuation. Pi can deliver queued user content during that stop; it remains persisted and ordered for the original model.
126
- 5. At `agent_settled`, after Pi has flushed custom messages, the controller verifies the exact durable marker and fresh checkpoint, restores original model/thinking, and sends one visible receipt-reading continuation. Pi 0.87 defers that continuation until every settled handler has returned, so later observers still see an idle session. Restoration finishes before the next provider request; marker `message_end` alone is not proof of persistence.
122
+ 1. `session_before_compact` distinguishes user-manual requests from Toolkit-owned maintenance even though both have reason `manual`. Resolve the producer and original consumer from one policy snapshot without invoking model/thinking setters.
123
+ 2. Prepare only the canonical effective current window. A later window retires an older opaque checkpoint; a newer checkpoint supersedes historical window markers. Preserve the prompt/tool head, context edits, complete tool pairs, native encrypted notes/history output and current deferred schemas. Hook-source preparation runs the ordered context phases once without triggering a budget reminder or maintenance. Unsafe projection cancels instead of recovering raw history.
124
+ 3. Send one synthetic Remote V2 request, validate it and recheck operation ownership, then return a real compaction result for Pi to persist. Select a genuine retained start within the submitted window. Resolution, protocol, network, validation and cancellation failures stop with no standalone/text-summary fallback, notes duty or continuation.
125
+ 4. `session_compact` synchronizes and verifies the latest exact persisted checkpoint. Repeated checkpoints share a summary string, so an event entry ID or summary match alone is insufficient proof. The callback returns without awaiting idle or sending a prompt: Pi emits it before clearing manual-compaction state.
126
+ 5. A cancellable bounded deferred check uses public `ctx.isIdle()` and exact session/branch/window/checkpoint/selection evidence before one normal `sendUserMessage` continuation. New user work, selection, navigation, queued work, another compact or shutdown invalidates stale continuation. Host queues stay untouched. If safe continuation is unavailable, keep the successful checkpoint and stop without inference.
127
127
 
128
- Versioned `pi-openai-toolkit:manual-compact` custom entries record operation/session/branch/window identities, exact model keys, original thinking and phase. They contain no credentials, transcript copy or note contents. Reload/navigation reads the current branch and only recovers still-owned selection; it never retries notes, rollover or paid inference. A newer user model/thinking selection ends ownership. Failed restoration stays durable and blocks further provider requests until recovery or an explicit user selection.
128
+ Compression preserves the same session/window identity and does not call `new_context`. Ordinary notes/history and explicit rollover remain available; a later queued or persisted window must exclude the retired opaque checkpoint. The original working model consumes the checkpoint only after it is durable and manual compression is idle. Pi's pre-hook empty/already-compacted outcomes remain unchanged.
129
129
 
130
- The underlying native compact promise still rejects its intentional cancellation. Empty/already-compacted sessions can fail before the extension hook. This is a separate handoff operation, not a fabricated successful native compaction. An observed user cancellation before the owned checkpoint stop restores selection without automatically resuming. Tests use the official Pi runtime, disk-backed sessions, isolated HOME/settings and synthetic Responses/native Codex traffic in [test/pi-managed-compact.test.ts](../test/pi-managed-compact.test.ts); they assert every request's model/thinking, marker/note order, retained tool head and queue preservation.
130
+ Old version-1 `pi-openai-toolkit:manual-compact` records remain decodable for owned selection recovery. Reload/navigation never retries their notes, rollover or inference; a newer user selection wins. New direct compression does not create notes-duty records or change the current model. Tests in [test/pi-managed-compact.test.ts](../test/pi-managed-compact.test.ts) use the official SDK, disk-backed sessions, isolated HOME/settings and mocked native/gateway traffic to verify input, persistence, continuation and failure ordering.
131
131
 
132
132
  ---
133
133
 
134
134
  ### Remote Compaction v2 wire contract
135
135
 
136
- v2 is what an uncovered session gets. Any model that Remote Context declines, such as a gateway without an exact transport opt-in, can still compact through v2 when its API appears in `context.remoteCompaction.apis`. Compaction generation follows the active strategy. Across mode changes, an already-persisted checkpoint remains authoritative until a safe newer window or compaction replaces it; a historical window identity alone must not suppress checkpoint replay.
136
+ V2 serves ordinary eligible compaction and active Remote Window user-manual compaction. A model that Remote Context declines, such as a gateway without an exact transport opt-in, can still use the ordinary V2 path when its API appears in `context.remoteCompaction.apis`. Active Remote manual compression uses the stricter current-window and stop-on-failure contract above. Compaction generation follows the active strategy. Across mode changes, an already-persisted checkpoint remains authoritative until a safe newer window or compaction replaces it; a historical window identity alone must not suppress checkpoint replay.
137
137
 
138
138
  A `compaction_trigger` item appended to the live streaming request yields one output item of `type: "compaction"` with non-empty `encrypted_content`, stored in `CompactionEntry.details.compactedWindow`. On later requests the opaque checkpoint is replayed ahead of live turns, with no text summary. Replay fails closed: if the summary anchor cannot be located, the request is aborted with a notification and a content-free failure artifact. The sentinel-only payload is never sent.
139
139
 
@@ -143,7 +143,7 @@ A missing, empty, or invalid V2 checkpoint is never stored. Ordinary compaction
143
143
 
144
144
  #### Official API-key standalone fallback
145
145
 
146
- The ordinary path reuses one resolved physical producer, auth snapshot, and prepared full input. After a qualifying V2 failure, it makes at most one POST to `https://api.openai.com/v1/responses/compact`. Eligibility requires `openai-responses`, no Codex gateway affinity, public `ModelRegistry.hasConfiguredAuth(model) === true` and `isUsingOAuth(model) === false`, and the final Authorization header matching the resolved Bearer key. Missing/throwing/nonboolean metadata is unknown, not API-key evidence. Provider aliases may qualify at the official endpoint; OAuth, native Codex, Azure, custom gateways, managed handoff and inactive Remote Context recovery retain their previous routing. No token-shape inspection, extra auth resolution, model/account switch, redirect, capability cache, or generated version/beta header is introduced.
146
+ The ordinary path reuses one resolved physical producer, auth snapshot, and prepared full input. After a qualifying V2 failure, it makes at most one POST to `https://api.openai.com/v1/responses/compact`. Eligibility requires `openai-responses`, no Codex gateway affinity, public `ModelRegistry.hasConfiguredAuth(model) === true` and `isUsingOAuth(model) === false`, and the final Authorization header matching the resolved Bearer key. Missing/throwing/nonboolean metadata is unknown, not API-key evidence. Provider aliases may qualify at the official endpoint; OAuth, native Codex, Azure, custom gateways, active Remote Window manual compression and inactive Remote Context recovery do not opt into this standalone fallback. No token-shape inspection, extra auth resolution, model/account switch, redirect, capability cache, or generated version/beta header is introduced.
147
147
 
148
148
  Qualifying failures are HTTP 400/404/405/422/501 without auth/quota exclusions, invalid/empty successful V2 checkpoint responses, or streamed errors with a recognized unsupported-protocol code. HTTP 401/403/429, known subscription/auth/billing/rate-limit restrictions, network failures, aborts, and generic server outages never trigger another protocol probe. OpenAI subscription-sharing OAuth and native Codex OAuth are distinct grants; selecting a separately authenticated Codex route does not authorize copying its credential to OpenAI.
149
149
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openai-toolkit",
3
- "version": "0.22.2",
3
+ "version": "0.23.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -1,9 +1,10 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { isDeepStrictEqual } from "node:util";
2
3
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
- import { buildSessionContext, estimateTokens, type ExtensionAPI, type ExtensionContext, type SessionBeforeCompactEvent, type SessionEntry } from "@earendil-works/pi-coding-agent";
4
+ import { buildSessionContext, estimateTokens, type CompactionResult, type ExtensionAPI, type ExtensionContext, type SessionBeforeCompactEvent, type SessionEntry } from "@earendil-works/pi-coding-agent";
4
5
  import { resolveToolkitConfig, type ResolvedToolkitConfig } from "../config";
5
6
  import { parseModelSpec } from "../runtime";
6
- import { THINKING_LEVELS } from "../types";
7
+ import { THINKING_LEVELS, type NativeCompactionDetails } from "../types";
7
8
  import { resolveCodexContextProvider } from "./codex-provider";
8
9
  import { findLatestNotesCheckpointSinceBoundary, findLatestWindowBoundaryEntry, type CodexContextWindowManager } from "./window-manager";
9
10
  import { isNonEmptyString, isRecord, type CodexContextProvider, type ContextWindowIdentity } from "./types";
@@ -79,7 +80,183 @@ function freshCheckpoint(entries: readonly SessionEntry[], record: ManualCompact
79
80
  return findLatestNotesCheckpointSinceBoundary(entries, record.sessionId, afterId);
80
81
  }
81
82
 
82
- /** Owns only manual managed handoffs, not normal window rollovers or Remote V2. */
83
+ function branchFingerprint(entries: readonly SessionEntry[]): string {
84
+ return createHash("sha256").update(JSON.stringify(entries)).digest("hex");
85
+ }
86
+
87
+ export type RemoteManualCompactOperation = {
88
+ generation: number;
89
+ sessionId: string;
90
+ sessionFile: string | undefined;
91
+ anchorId: string;
92
+ branchLength: number;
93
+ branchFingerprint: string;
94
+ windowId: string;
95
+ model: string;
96
+ api: string;
97
+ baseUrl: string;
98
+ thinking: ThinkingLevel;
99
+ controller: AbortController;
100
+ signal: AbortSignal;
101
+ removeAbortListeners: () => void;
102
+ policy: ResolvedToolkitConfig;
103
+ candidate?: CompactionResult<NativeCompactionDetails>;
104
+ persistedId?: string;
105
+ deadline?: number;
106
+ };
107
+
108
+ /**
109
+ * One transient Remote V2 operation. It never selects a model, writes recovery
110
+ * records, takes host queues, or resumes from reload. Legacy duty recovery below
111
+ * remains independent.
112
+ */
113
+ export class RemoteManualCompact {
114
+ private generation = 0;
115
+ private operation: RemoteManualCompactOperation | undefined;
116
+ private timer: ReturnType<typeof setTimeout> | undefined;
117
+
118
+ constructor(private readonly pi: ExtensionAPI, private readonly windows: CodexContextWindowManager,
119
+ private readonly idleTimeoutMs = 10_000) {}
120
+
121
+ get snapshot() { return this.operation?.policy.snapshot; }
122
+
123
+ invalidate(): void {
124
+ ++this.generation;
125
+ clearTimeout(this.timer);
126
+ this.timer = undefined;
127
+ const operation = this.operation;
128
+ this.operation = undefined;
129
+ operation?.removeAbortListeners();
130
+ operation?.controller.abort();
131
+ }
132
+
133
+ cancel(operation: RemoteManualCompactOperation | undefined): void {
134
+ if (operation && this.operation === operation) this.invalidate();
135
+ }
136
+
137
+ begin(event: SessionBeforeCompactEvent, ctx: ExtensionContext, policy: ResolvedToolkitConfig): RemoteManualCompactOperation | undefined {
138
+ this.invalidate();
139
+ const model = ctx.model;
140
+ const anchorId = ctx.sessionManager.getLeafId();
141
+ const windowId = this.windows.currentIdentity()?.currentWindowId;
142
+ if (!model || !anchorId || !windowId || event.signal.aborted) return undefined;
143
+ const branch = ctx.sessionManager.getBranch();
144
+ if (!isDeepStrictEqual(event.branchEntries, branch)) return undefined;
145
+ const controller = new AbortController();
146
+ const onAbort = () => { if (this.operation === operation) this.invalidate(); };
147
+ // Manual compact has already aborted the preceding agent run. Its old,
148
+ // aborted ctx.signal is not cancellation of this newly initiated operation.
149
+ const signals = [event.signal, ...(ctx.signal && !ctx.signal.aborted && ctx.signal !== event.signal ? [ctx.signal] : [])];
150
+ const operation: RemoteManualCompactOperation = {
151
+ generation: this.generation, sessionId: ctx.sessionManager.getSessionId(),
152
+ sessionFile: ctx.sessionManager.getSessionFile(), anchorId,
153
+ branchLength: branch.length, branchFingerprint: branchFingerprint(branch), windowId,
154
+ model: modelKey(model)!, api: model.api, baseUrl: model.baseUrl, thinking: this.pi.getThinkingLevel(),
155
+ controller, signal: controller.signal, policy,
156
+ removeAbortListeners: () => { for (const signal of signals) signal.removeEventListener("abort", onAbort); },
157
+ };
158
+ this.operation = operation;
159
+ for (const signal of signals) signal.addEventListener("abort", onAbort, { once: true });
160
+ return operation;
161
+ }
162
+
163
+ private owns(operation: RemoteManualCompactOperation, ctx: ExtensionContext): boolean {
164
+ if (this.operation !== operation || operation.generation !== this.generation || operation.signal.aborted
165
+ || ctx.sessionManager.getSessionId() !== operation.sessionId || ctx.sessionManager.getSessionFile() !== operation.sessionFile
166
+ || modelKey(ctx.model) !== operation.model || ctx.model?.api !== operation.api || ctx.model?.baseUrl !== operation.baseUrl
167
+ || this.pi.getThinkingLevel() !== operation.thinking || ctx.hasPendingMessages()) return false;
168
+ this.windows.synchronize(ctx);
169
+ return this.windows.currentIdentity()?.currentWindowId === operation.windowId && !this.windows.hasPendingRollover(ctx);
170
+ }
171
+
172
+ current(operation: RemoteManualCompactOperation, ctx: ExtensionContext): boolean {
173
+ try {
174
+ const branch = ctx.sessionManager.getBranch();
175
+ return this.owns(operation, ctx) && ctx.sessionManager.getLeafId() === operation.anchorId
176
+ && branch.length === operation.branchLength && branchFingerprint(branch) === operation.branchFingerprint;
177
+ } catch { return false; }
178
+ }
179
+
180
+ arm(
181
+ operation: RemoteManualCompactOperation,
182
+ ctx: ExtensionContext,
183
+ candidate: CompactionResult<NativeCompactionDetails>,
184
+ ): { readonly cancel: boolean; compaction: CompactionResult<NativeCompactionDetails> } | undefined {
185
+ if (!this.current(operation, ctx)) return undefined;
186
+ operation.candidate = structuredClone(candidate);
187
+ const owner = this;
188
+ return {
189
+ compaction: candidate,
190
+ // Pi reads this public result again after later before-compact observers.
191
+ // Revalidate at consumption, not just at return: newer work must revoke
192
+ // the candidate before persistence, without ctx.abort() draining TUI queues.
193
+ get cancel() { return !owner.current(operation, ctx); },
194
+ };
195
+ }
196
+
197
+ private durableCandidate(operation: RemoteManualCompactOperation, ctx: ExtensionContext): string | undefined {
198
+ if (!this.owns(operation, ctx) || !operation.candidate) return undefined;
199
+ const branch = ctx.sessionManager.getBranch();
200
+ // The event's ID can refer to an OLD equal-summary checkpoint in Pi 1.0.
201
+ // Only the new actual branch child and its complete candidate are proof.
202
+ if (branch.length !== operation.branchLength + 1
203
+ || branchFingerprint(branch.slice(0, operation.branchLength)) !== operation.branchFingerprint) return undefined;
204
+ const entry = branch[operation.branchLength];
205
+ const candidate = operation.candidate;
206
+ if (!entry || entry.type !== "compaction" || entry.parentId !== operation.anchorId
207
+ || entry.id === operation.anchorId || ctx.sessionManager.getLeafId() !== entry.id || entry.fromHook !== true
208
+ || entry.summary !== candidate.summary || entry.firstKeptEntryId !== candidate.firstKeptEntryId
209
+ || entry.tokensBefore !== candidate.tokensBefore
210
+ // Persistence omits undefined optional fields; compare the actual JSON
211
+ // data, not object identity, summary text, or provider response ID alone.
212
+ || !isDeepStrictEqual(JSON.parse(JSON.stringify(entry.details)), JSON.parse(JSON.stringify(candidate.details)))) return undefined;
213
+ return entry.id;
214
+ }
215
+
216
+ private requestManualResume(ctx: ExtensionContext, message = "Remote /compact saved its checkpoint; resume manually."): void {
217
+ try { if (ctx.hasUI) ctx.ui.notify(message, "warning"); } catch { /* stale UI must not escape a deferred callback */ }
218
+ }
219
+
220
+ /** Observer only: never await idle or submit a prompt inside session_compact. */
221
+ persisted(ctx: ExtensionContext): void {
222
+ const operation = this.operation;
223
+ if (!operation?.candidate || operation.persistedId) return;
224
+ try { operation.persistedId = this.durableCandidate(operation, ctx); } catch { /* fail closed */ }
225
+ if (!operation.persistedId) {
226
+ this.invalidate();
227
+ this.requestManualResume(ctx, "Remote /compact could not verify its exact saved checkpoint; resume manually.");
228
+ return;
229
+ }
230
+ operation.deadline = Date.now() + this.idleTimeoutMs;
231
+ this.schedule(operation, ctx, 0);
232
+ }
233
+
234
+ private schedule(operation: RemoteManualCompactOperation, ctx: ExtensionContext, delay: number): void {
235
+ this.timer = setTimeout(() => {
236
+ this.timer = undefined;
237
+ if (this.operation !== operation) return;
238
+ try {
239
+ if (this.durableCandidate(operation, ctx) !== operation.persistedId) { this.invalidate(); return; }
240
+ if (Date.now() >= operation.deadline!) {
241
+ this.invalidate();
242
+ this.requestManualResume(ctx, "Remote /compact saved its checkpoint; automatic continuation could not safely reach idle. Resume manually.");
243
+ return;
244
+ }
245
+ if (!ctx.isIdle()) { this.schedule(operation, ctx, 25); return; }
246
+ // No await between final public ownership/queue/idle proof and send.
247
+ // Disarm first, so our own input/run events cannot invalidate a newer operation.
248
+ this.invalidate();
249
+ this.pi.sendUserMessage("Context compressed. Resume the active user task.");
250
+ } catch {
251
+ this.invalidate();
252
+ this.requestManualResume(ctx);
253
+ }
254
+ }, delay);
255
+ this.timer.unref?.();
256
+ }
257
+ }
258
+
259
+ /** Owns only legacy managed handoffs, not normal window rollovers or Remote V2. */
83
260
  export class ManagedManualCompact implements ContextRolloverHandoff {
84
261
  private record: ManualCompactRecord | undefined;
85
262
  private generation = 0;
@@ -1,4 +1,6 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { randomUUID } from "node:crypto";
3
+ import { isDeepStrictEqual } from "node:util";
2
4
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
5
  import type { ProviderHeaders } from "@earendil-works/pi-ai";
4
6
  import {
@@ -65,6 +67,17 @@ type WindowBoundaryEntry = Extract<SessionEntry, { type: "custom_message" }> & {
65
67
  details: CodexContextManagementMessageDetails;
66
68
  };
67
69
 
70
+ /** Canonical input and real source anchors for one read-only manual compression. */
71
+ export type RemoteWindowCompactionInput = {
72
+ messages: AgentMessage[];
73
+ firstKeptEntryId: string;
74
+ /** Only a newer compaction can own this window; pre-window checkpoints are retired. */
75
+ owningCompaction?: Extract<SessionEntry, { type: "compaction" }>;
76
+ /** Entries whose visible contributions Pi will retain alongside the new checkpoint. */
77
+ retainedMessages: AgentMessage[];
78
+ windowBoundaryIndex: number;
79
+ };
80
+
68
81
  /**
69
82
  * In-process duplicate guard for one scheduled rollover. It is anchored to the
70
83
  * session that scheduled it and to the exact target window id, so it can never
@@ -88,6 +101,14 @@ export class CodexContextWindowManager {
88
101
  private pendingRollover: PendingRollover | undefined;
89
102
  private readonly projectionDiagnostics = new Set<string>();
90
103
  private lastKnownSystemHead: AgentMessage | undefined;
104
+ private readonly compactionProjection = new AsyncLocalStorage<boolean>();
105
+
106
+ get isPreparingCompaction(): boolean { return this.compactionProjection.getStore() === true; }
107
+
108
+ /** Keep read-only preparation scoped across awaits without affecting other live requests. */
109
+ withCompactionProjection<T>(prepare: () => Promise<T>): Promise<T> {
110
+ return this.compactionProjection.run(true, prepare);
111
+ }
91
112
  /**
92
113
  * Pi's getContextUsage() measures the durable transcript. Remote windows are
93
114
  * projected only by this extension, so remember the last provider-visible
@@ -225,6 +246,7 @@ export class CodexContextWindowManager {
225
246
  /** Delay encoding until verified retained copies have been removed. */
226
247
  encodeEncryptedOutputs = true,
227
248
  ): AgentMessage[] {
249
+ if (this.isPreparingCompaction) return this.projectCompactionContext(messages);
228
250
  // Recompute the request-only preview on every projection. Observing a
229
251
  // queued marker must not authorize maintenance or retire the duplicate guard.
230
252
  if (this.pendingRollover) this.pendingRollover.projected = false;
@@ -278,6 +300,78 @@ export class CodexContextWindowManager {
278
300
  return projected;
279
301
  }
280
302
 
303
+ /**
304
+ * Strict, read-only projection. Unlike live Remote projection, an unsafe head
305
+ * never falls back to the full transcript, updates identity, or records budget.
306
+ */
307
+ projectCompactionContext(messages: readonly AgentMessage[], encodeEncryptedOutputs = false): AgentMessage[] {
308
+ if (this.pendingRollover || (this.boundary.kind !== "effective" && this.boundary.kind !== "superseded")) {
309
+ throw new Error("unsafe-compaction-window-boundary");
310
+ }
311
+ let boundaryIndex = 0;
312
+ if (this.boundary.kind === "effective") {
313
+ const boundary = this.boundary.entry;
314
+ boundaryIndex = messages.findIndex((message) => isContextWindowBoundary(message)
315
+ && isDeepStrictEqual(message.details, boundary.details)
316
+ && isDeepStrictEqual(message.content, boundary.content));
317
+ if (boundaryIndex < 0) throw new Error("compaction-window-boundary-missing");
318
+ }
319
+ const repair = preserveSystemHead({ messages, boundaryIndex });
320
+ if (!repair.head || repair.shrinkRejected) throw new Error("compaction-system-tool-head-lost");
321
+ return encodeEncryptedOutputs ? projectEncryptedToolResults(repair.messages) : [...repair.messages];
322
+ }
323
+
324
+ prepareRemoteCompaction(event: SessionBeforeCompactEvent, ctx: ExtensionContext): RemoteWindowCompactionInput {
325
+ this.synchronize(ctx);
326
+ if (this.hasPendingRollover(ctx) || (this.boundary.kind !== "effective" && this.boundary.kind !== "superseded")) {
327
+ throw new Error("unsafe-compaction-window-boundary");
328
+ }
329
+ const branch = ctx.sessionManager.getBranch();
330
+ if (!isDeepStrictEqual(event.branchEntries, branch)) throw new Error("compaction-branch-changed");
331
+ const projection = ctx.sessionManager.buildSessionProjection();
332
+ // Never manufacture source IDs or substitute a raw-history reconstruction.
333
+ if (!projection || !Array.isArray(projection.entries) || !Array.isArray(projection.messages)
334
+ || !isDeepStrictEqual(projection.messages, projection.entries.flatMap((entry) => entry.messages))
335
+ || new Set(projection.entries.map((entry) => entry.sourceEntry.id)).size !== projection.entries.length
336
+ || projection.entries.some((entry) => !branch.some((raw) => raw.id === entry.sourceEntry.id && isDeepStrictEqual(raw, entry.sourceEntry)))) {
337
+ throw new Error("compaction-source-projection-unavailable");
338
+ }
339
+ const windowBoundaryIndex = branch.findIndex((entry) => entry.id === this.boundaryEntryId());
340
+ if (windowBoundaryIndex < 0) throw new Error("compaction-window-boundary-missing");
341
+ const latest = [...branch].reverse().find((entry) => entry.type === "compaction");
342
+ if (this.boundary.kind === "effective" && this.boundary.cleaned) {
343
+ // Details alone do not turn an arbitrary summary into Toolkit cleanup.
344
+ if (!latest || latest.type !== "compaction" || latest.fromHook !== true
345
+ || latest.summary !== CONTEXT_WINDOW_COMPACTION_SUMMARY
346
+ || !isContextWindowCompactionDetails(latest.details)
347
+ || latest.details.windowId !== this.boundary.entry.details.contextManagement.currentWindowId
348
+ || latest.firstKeptEntryId !== this.boundary.entry.id) throw new Error("unverified-window-cleanup");
349
+ }
350
+ const owningCompaction = this.boundary.kind === "superseded" && latest?.type === "compaction" ? latest : undefined;
351
+ if (this.boundary.kind === "superseded" && !owningCompaction) throw new Error("compaction-owner-missing");
352
+ const lowerId = owningCompaction?.id ?? this.boundary.entry.id;
353
+ const lowerIndex = branch.findIndex((entry) => entry.id === lowerId);
354
+ const requestedIndex = branch.findIndex((entry) => entry.id === event.preparation.firstKeptEntryId);
355
+ if (requestedIndex < 0 || lowerIndex < windowBoundaryIndex) throw new Error("unsafe-compaction-retained-start");
356
+ const firstKeptEntryId = requestedIndex < lowerIndex ? lowerId : event.preparation.firstKeptEntryId;
357
+ const keptIndex = Math.max(requestedIndex, lowerIndex);
358
+ const safeSources = new Set(projection.entries.filter((entry) => entry.messages.length > 0).map((entry) => entry.sourceEntry.id));
359
+ if (!safeSources.has(firstKeptEntryId)) throw new Error("unsafe-compaction-retained-start");
360
+ const retainedIds = new Set(branch.slice(keptIndex).map((entry) => entry.id));
361
+ return {
362
+ messages: this.projectCompactionContext(structuredClone(projection.messages)),
363
+ firstKeptEntryId,
364
+ owningCompaction,
365
+ retainedMessages: structuredClone(projection.entries.filter((entry) => retainedIds.has(entry.sourceEntry.id)
366
+ && entry.sourceEntry.type !== "compaction").flatMap((entry) => entry.messages)),
367
+ windowBoundaryIndex,
368
+ };
369
+ }
370
+
371
+ private boundaryEntryId(): string | undefined {
372
+ return this.boundary.kind === "effective" || this.boundary.kind === "superseded" ? this.boundary.entry.id : undefined;
373
+ }
374
+
281
375
  /**
282
376
  * Trim everything before the window boundary while keeping Pi's prompt/tool head.
283
377
  *
@@ -449,14 +543,24 @@ export class CodexContextWindowManager {
449
543
  * Detach a local consumer at the durable Remote boundary, without summarizing
450
544
  * any Remote-managed history. Unlike a scheduled rollover trim, this needs
451
545
  * no pending trim and remains retryable until Pi persists the result.
546
+ *
547
+ * A consumed boundary is not an error. Once Pi has persisted the no-summary
548
+ * boundary, `buildSessionProjection()` already starts at that marker, so
549
+ * retired Remote history is outside every producer's input and the ordinary
550
+ * compaction chain may summarize the remaining local window. Returning
551
+ * `undefined` hands the event back to that chain; returning `{ cancel: true }`
552
+ * would strand the session with no working compaction path at all.
452
553
  */
453
554
  prepareDetachedCompaction(
454
555
  event: SessionBeforeCompactEvent,
455
- ): { cancel: true } | { compaction: CompactionResult<ContextWindowCompactionDetails> } | undefined {
556
+ ): { compaction: CompactionResult<ContextWindowCompactionDetails> } | undefined {
456
557
  const boundary = resolveWindowBoundary(event.branchEntries, this.sessionId);
457
558
  if (boundary.kind === "ambiguous") throw new Error("Persisted Codex context-window boundary missing or edited");
458
559
  if (boundary.kind !== "effective") return undefined;
459
- if (boundary.cleaned) return { cancel: true };
560
+ // A matching no-summary boundary is already the effective compaction anchor.
561
+ // Nothing is left to detach, and writing a second boundary for the same window
562
+ // would move the retained start backwards.
563
+ if (boundary.cleaned) return undefined;
460
564
  const compaction = this.createCompaction(event, boundary.entry);
461
565
  // Pi's preparation/tokensBefore describes the durable branch, which includes
462
566
  // Remote-managed history. Detached local accounting must use the same current
@@ -1,4 +1,7 @@
1
+ import { isDeepStrictEqual } from "node:util";
1
2
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
+ import { declarationsEqual, getCurrentSystemMessage, getCurrentTools } from "@earendil-works/pi-ai";
4
+ import { convertResponsesTools } from "@earendil-works/pi-ai/api/openai-responses-shared";
2
5
  import type {
3
6
  BeforeProviderRequestEvent,
4
7
  ContextWithSystemEvent,
@@ -16,10 +19,13 @@ import {
16
19
  isCodexGatewayModel,
17
20
  isNativeCodexModel,
18
21
  } from "./context-management/codex-provider";
19
- import { routeContextNamespaceToolMessage } from "./context-management/namespace-tools";
22
+ import { rewriteContextNamespaceCalls, rewriteContextNamespaceTools, routeContextNamespaceToolMessage } from "./context-management/namespace-tools";
23
+ import { rewriteEncryptedToolOutputs } from "./context-management/window-request";
20
24
  import { loadHistoryNotesThreadHint } from "./context-management/history-notes";
21
25
  import { CodexContextWindowManager } from "./context-management/window-manager";
22
- import { ManagedManualCompact } from "./context-management/manual-compact";
26
+ import { ManagedManualCompact, RemoteManualCompact, type RemoteManualCompactOperation } from "./context-management/manual-compact";
27
+ import { isSystemTranscriptMessage } from "./context-management/messages";
28
+ import { isRecord } from "./context-management/types";
23
29
  import {
24
30
  registerContextManagementTools,
25
31
  type ContextToolRegistrationState,
@@ -79,7 +85,9 @@ import {
79
85
  getRemoteV2InputProvenance,
80
86
  isNativeCompactionDetails,
81
87
  type CompactionConfig,
88
+ type DeferredToolCarryoverV1,
82
89
  type NativeCompactionDetails,
90
+ type NativeCompactionEntry,
83
91
  type NativeCompactionRequestMeta,
84
92
  } from "./types";
85
93
 
@@ -116,6 +124,7 @@ type CompactionDependencies = {
116
124
  nativeFallback: typeof runNativeFallbackCompaction;
117
125
  contextWindows: CodexContextWindowManager;
118
126
  manualCompact?: ManagedManualCompact;
127
+ remoteManualCompact: RemoteManualCompact;
119
128
  /** Test seam for the same ordered projection exposed by the Pi host patch. */
120
129
  projectCompactionContext?: CompactionContextProjection;
121
130
  piContextHookPatch: PiContextHookPatchResult;
@@ -311,8 +320,9 @@ type CompactionProjectionResult =
311
320
  async function projectSessionContextForCompaction(
312
321
  ctx: ExtensionContext,
313
322
  projectCompactionContext: CompactionContextProjection | undefined,
323
+ canonicalMessages?: readonly AgentMessage[],
314
324
  ): Promise<CompactionProjectionResult> {
315
- const messages = readSessionContextMessages(ctx);
325
+ const messages = canonicalMessages ? structuredClone([...canonicalMessages]) : readSessionContextMessages(ctx);
316
326
  if (!messages) {
317
327
  return { ok: false, reason: COMPACTION_SESSION_CONTEXT_UNAVAILABLE };
318
328
  }
@@ -342,6 +352,187 @@ function getLegacySessionContextMessages(
342
352
  ];
343
353
  }
344
354
 
355
+ type ScopedResponsesCompactInput = {
356
+ request: NativeCompactionRequestBody;
357
+ firstKeptEntryId: string;
358
+ deferredToolCarryover?: DeferredToolCarryoverV1;
359
+ requestSource: "session-context" | "non-native-session-context" | "latest-native-replay";
360
+ isCurrent: () => boolean;
361
+ };
362
+
363
+ /** Reject incomplete/ambiguous tool batches rather than let serialization invent results. */
364
+ function requireCompleteCompactionToolPairs(messages: readonly AgentMessage[]): ReadonlyMap<string, string | undefined> {
365
+ const calls = new Map<string, string | undefined>();
366
+ const pending = new Set<string>();
367
+ for (const message of messages) {
368
+ if (message.role === "assistant") {
369
+ if (pending.size) throw new Error("compaction-tool-pair-mismatch");
370
+ for (const block of message.content) {
371
+ if (block.type !== "toolCall") continue;
372
+ const callId = block.id.split("|")[0];
373
+ if (!callId || calls.has(callId)) throw new Error("compaction-tool-pair-mismatch");
374
+ calls.set(callId, block.namespace);
375
+ pending.add(block.id);
376
+ }
377
+ } else if (message.role === "toolResult") {
378
+ if (!pending.delete(message.toolCallId)) throw new Error("compaction-tool-pair-mismatch");
379
+ } else if (pending.size && !isSystemTranscriptMessage(message)) {
380
+ throw new Error("compaction-tool-pair-mismatch");
381
+ }
382
+ }
383
+ if (pending.size) throw new Error("compaction-tool-pair-mismatch");
384
+ return calls;
385
+ }
386
+
387
+ /** Serialization must preserve every verified pair, with unique provider call IDs. */
388
+ function requireCompactionWireToolPairs(input: readonly unknown[], namespaces: ReadonlyMap<string, string | undefined>): void {
389
+ const calls = new Set<string>();
390
+ const pending = new Set<string>();
391
+ for (const item of input) {
392
+ if (!isRecord(item)) throw new Error("compaction-tool-pair-mismatch");
393
+ if (item.type === "function_call") {
394
+ if (typeof item.call_id !== "string" || !namespaces.has(item.call_id) || calls.has(item.call_id)) {
395
+ throw new Error("compaction-tool-pair-mismatch");
396
+ }
397
+ calls.add(item.call_id);
398
+ pending.add(item.call_id);
399
+ } else if (item.type === "function_call_output") {
400
+ if (typeof item.call_id !== "string" || !pending.delete(item.call_id)) throw new Error("compaction-tool-pair-mismatch");
401
+ }
402
+ }
403
+ if (pending.size || calls.size !== namespaces.size) throw new Error("compaction-tool-pair-mismatch");
404
+ }
405
+
406
+ function requireCompactionHeadAndRetention(
407
+ canonical: readonly AgentMessage[],
408
+ projected: readonly AgentMessage[],
409
+ retained: readonly AgentMessage[],
410
+ ): void {
411
+ const before = getCurrentTools(canonical);
412
+ const after = getCurrentTools(projected);
413
+ if (!getCurrentSystemMessage(projected) || before.length !== after.length
414
+ || before.some((tool, index) => !after[index] || !declarationsEqual(tool, after[index]!))) {
415
+ throw new Error("compaction-system-tool-head-lost");
416
+ }
417
+ // These exact source contributions will accompany the new summary in Pi.
418
+ // If a hook rewrites/omits one, future strict retained-copy removal cannot be proven.
419
+ let cursor = 0;
420
+ for (const message of retained) {
421
+ if (message.role === "custom" || message.role === "compactionSummary" || isSystemTranscriptMessage(message)) continue;
422
+ const index = projected.findIndex((candidate, index) => index >= cursor && isDeepStrictEqual(candidate, message));
423
+ if (index < 0) throw new Error("compaction-retained-context-mismatch");
424
+ cursor = index + 1;
425
+ }
426
+ }
427
+
428
+ /** Apply only pure wire adapters, never ordinary turn/history-ingest hooks. */
429
+ function adaptCompactionWireInput(input: ResponsesInputItem[], namespaces: ReadonlyMap<string, string | undefined>): ResponsesInputItem[] {
430
+ // The shared legacy serializer omits namespace. Restore it from the verified
431
+ // canonical call before applying the existing context-router wire adapter.
432
+ const namespaced = input.map((item: unknown) => {
433
+ if (!isRecord(item) || item.type !== "function_call" || typeof item.call_id !== "string") return item;
434
+ const namespace = namespaces.get(item.call_id);
435
+ return namespace ? { ...item, namespace } : item;
436
+ });
437
+ const adapted = rewriteEncryptedToolOutputs(rewriteContextNamespaceCalls({ input: namespaced }));
438
+ if (!isRecord(adapted) || !Array.isArray(adapted.input)) throw new Error("compaction-wire-adaptation-failed");
439
+ // The adapters preserve the validated array shape, with native encrypted outputs.
440
+ return adapted.input as ResponsesInputItem[];
441
+ }
442
+
443
+ async function prepareActiveRemoteCompaction(
444
+ event: SessionBeforeCompactEvent,
445
+ ctx: ExtensionContext,
446
+ config: CompactionConfig,
447
+ execution: RemoteCompactionExecution,
448
+ dependencies: CompactionDependencies,
449
+ isCurrent: () => boolean,
450
+ ): Promise<ScopedResponsesCompactInput> {
451
+ const windows = dependencies.contextWindows;
452
+ const prepared = windows.prepareRemoteCompaction(event, ctx);
453
+ const branch = ctx.sessionManager.getBranch();
454
+ const provenance = getRemoteV2InputProvenance(config.remoteV2ContextSource);
455
+ let checkpoint: NativeCompactionEntry | undefined;
456
+ let requestSource: ScopedResponsesCompactInput["requestSource"] = "session-context";
457
+ if (prepared.owningCompaction) {
458
+ const latest = resolveLatestNativeCompactionEntry(branch, { baseUrl: execution.consumer.baseUrl });
459
+ if (latest.ok) {
460
+ if (latest.entry.id !== prepared.owningCompaction.id) throw new Error("compaction-owner-mismatch");
461
+ if (hasInvalidatedCompactionContext(branch, latest.entry.id)) throw new Error(COMPACTION_CHECKPOINT_CONTEXT_EDITED);
462
+ if (!hasVerifiedCompactionInputProvenance(latest.entry.details, provenance)) throw new Error(COMPACTION_CHECKPOINT_PROVENANCE_UNAVAILABLE);
463
+ checkpoint = latest.entry;
464
+ requestSource = "latest-native-replay";
465
+ } else {
466
+ if (latest.reason !== "latest-compaction-not-native" || !config.allowCompactionContinuityBreak) throw new Error("compaction-continuity-unavailable");
467
+ requestSource = "non-native-session-context";
468
+ }
469
+ }
470
+ let messages = prepared.messages;
471
+ if (config.remoteV2ContextSource === "pi-context-hook") {
472
+ const projection = await windows.withCompactionProjection(() => projectSessionContextForCompaction(
473
+ ctx, dependencies.projectCompactionContext, messages,
474
+ ));
475
+ if (!isCurrent()) throw new Error("compaction-operation-superseded");
476
+ if (!projection.ok) throw new Error(projection.reason);
477
+ messages = windows.projectCompactionContext(projection.messages);
478
+ }
479
+ if (!isCurrent()) throw new Error("compaction-operation-superseded");
480
+ requireCompactionHeadAndRetention(prepared.messages, messages, prepared.retainedMessages);
481
+ if (checkpoint) {
482
+ // The synthetic Toolkit hook deliberately leaves these copies alone. Match
483
+ // exactly once, BEFORE encrypted-output markers change the canonical values.
484
+ const pruned = removeNativeCompactionRetainedMessages({
485
+ messages, branchEntries: branch, compactionEntry: checkpoint, expectedInputProvenance: provenance,
486
+ });
487
+ if (!pruned.ok) throw new Error(pruned.reason);
488
+ messages = pruned.messages;
489
+ }
490
+ const namespaces = requireCompleteCompactionToolPairs(messages);
491
+ const tools = getCurrentTools(messages);
492
+ const model = execution.compactor.currentModel;
493
+ const compat = isRecord(model.compat) ? model.compat : {};
494
+ const nativeCodex = model.api === "openai-codex-responses";
495
+ const encoded = windows.projectCompactionContext(messages, true);
496
+ let input: ResponsesInputItem[];
497
+ if (checkpoint) {
498
+ const summaryIndex = findLatestProjectedCompactionSummaryIndex(encoded, checkpoint.summary);
499
+ if (summaryIndex < 0 || encoded.filter((message) => message.role === "compactionSummary").length !== 1) {
500
+ throw new Error(COMPACTION_PROJECTED_CONTEXT_UNAVAILABLE);
501
+ }
502
+ const leading = await serializeMessagesToResponsesInputWithLimits(model, encoded.slice(0, summaryIndex));
503
+ if (!isCurrent()) throw new Error("compaction-operation-superseded");
504
+ const tail = await serializeMessagesToResponsesInputWithLimits(model, encoded.slice(summaryIndex + 1), { firstSystemMessageIsUpdate: true });
505
+ requireCompactionWireToolPairs([...leading, ...tail], namespaces);
506
+ const details = checkpoint.details;
507
+ if (!details) throw new Error("compaction-owner-mismatch");
508
+ input = [...adaptCompactionWireInput(leading, namespaces), ...(cloneOpaqueWindow(details.compactedWindow) as ResponsesInputItem[]), ...adaptCompactionWireInput(tail, namespaces)];
509
+ } else {
510
+ const serialized = await serializeMessagesToResponsesInputWithLimits(model, encoded);
511
+ requireCompactionWireToolPairs(serialized, namespaces);
512
+ input = adaptCompactionWireInput(serialized, namespaces);
513
+ }
514
+ if (!isCurrent()) throw new Error("compaction-operation-superseded");
515
+ const toolPayload = rewriteContextNamespaceTools({ tools: convertResponsesTools(tools, {
516
+ ...(nativeCodex ? { strict: null } : {}),
517
+ supportsStrictMode: typeof compat.supportsStrictMode === "boolean" ? compat.supportsStrictMode : nativeCodex,
518
+ supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools === true,
519
+ }) }, { encrypted: true });
520
+ if (!isRecord(toolPayload) || !Array.isArray(toolPayload.tools)) throw new Error("compaction-wire-adaptation-failed");
521
+ // Current cache-stack state is authoritative; never borrow deferred metadata
522
+ // from an unrelated pre-window checkpoint. The canonical head proves which
523
+ // of those schemas are still live, including tools activated before rollover.
524
+ const activation = getLatestDeferredToolCarryover(branch) ?? checkpoint?.details?.deferredToolCarryover;
525
+ const names = new Set(tools.map((tool) => tool.name));
526
+ const deferredToolCarryover = activation ? { ...activation, toolNames: activation.toolNames.filter((name) => names.has(name)) } : undefined;
527
+ return {
528
+ request: { model: execution.compactor.requestModel ?? execution.compactor.model, input,
529
+ instructions: buildCompactionInstructions(ctx.getSystemPrompt(), event.customInstructions), tools: toolPayload.tools },
530
+ firstKeptEntryId: prepared.firstKeptEntryId,
531
+ deferredToolCarryover: deferredToolCarryover?.toolNames.length ? deferredToolCarryover : undefined,
532
+ requestSource, isCurrent,
533
+ };
534
+ }
535
+
345
536
  async function runResponsesNativeCompact(
346
537
  event: SessionBeforeCompactEvent,
347
538
  ctx: ExtensionContext,
@@ -351,11 +542,13 @@ async function runResponsesNativeCompact(
351
542
  projectCompactionContext: CompactionContextProjection | undefined,
352
543
  /** Only ordinary compaction supplies this; managed recovery remains V2-only. */
353
544
  standaloneCompact?: typeof executeNativeCompaction,
545
+ /** Active Remote manual input is strict/current-window-only and has no fallback. */
546
+ scopedInput?: ScopedResponsesCompactInput,
354
547
  ): Promise<ResponsesCompactOutcome> {
355
548
  const { consumer, compactor } = execution;
356
549
  const instructions = buildCompactionInstructions(ctx.getSystemPrompt(), event.customInstructions);
357
550
  const branchEntries = event.branchEntries ?? ctx.sessionManager.getBranch();
358
- const deferredToolCarryover = getLatestDeferredToolCarryover(branchEntries);
551
+ const deferredToolCarryover = scopedInput ? scopedInput.deferredToolCarryover : getLatestDeferredToolCarryover(branchEntries);
359
552
  const latestNativeCompaction = resolveLatestNativeCompactionEntry(branchEntries, {
360
553
  baseUrl: consumer.baseUrl,
361
554
  });
@@ -363,7 +556,10 @@ async function runResponsesNativeCompact(
363
556
  const inputProvenance = getRemoteV2InputProvenance(config.remoteV2ContextSource);
364
557
  let requestSource: "session-context" | "non-native-session-context" | "latest-native-replay";
365
558
  let request: NativeCompactionRequestBody;
366
- if (latestNativeCompaction.ok) {
559
+ if (scopedInput) {
560
+ request = scopedInput.request;
561
+ requestSource = scopedInput.requestSource;
562
+ } else if (latestNativeCompaction.ok) {
367
563
  if (hasInvalidatedCompactionContext(branchEntries, latestNativeCompaction.entry.id)) {
368
564
  return { outcome: "unprojected-input", reason: COMPACTION_CHECKPOINT_CONTEXT_EDITED };
369
565
  }
@@ -482,10 +678,10 @@ async function runResponsesNativeCompact(
482
678
  sessionId: getSessionId(ctx),
483
679
  }, compactor.currentModel);
484
680
  if (extras) {
485
- request = { ...request, ...extras };
681
+ request = { ...request, ...extras, ...(scopedInput ? { tools: scopedInput.request.tools } : {}) };
486
682
  }
487
683
 
488
- if (event.signal?.aborted) return { outcome: "aborted" };
684
+ if (event.signal?.aborted || (scopedInput && !scopedInput.isCurrent())) return { outcome: "aborted" };
489
685
  let protocol: "remote-v2" | "standalone-compact" = "remote-v2";
490
686
  let compactResult: Awaited<ReturnType<typeof executeRemoteV2Compaction>> | NativeCompactionClientResult = await remoteCompact({
491
687
  runtime: compactor,
@@ -495,6 +691,7 @@ async function runResponsesNativeCompact(
495
691
  context: ctx,
496
692
  });
497
693
 
694
+ if (scopedInput && !scopedInput.isCurrent()) return { outcome: "aborted" };
498
695
  if (!compactResult.ok) {
499
696
  if (compactResult.reason === "aborted" || event.signal?.aborted) return { outcome: "aborted" };
500
697
  const tryStandalone = !!standaloneCompact && isStandaloneCompactionEligible(compactor) && qualifiesForStandaloneCompaction(compactResult);
@@ -507,7 +704,7 @@ async function runResponsesNativeCompact(
507
704
  errorType: compactResult.errorType,
508
705
  errorParam: compactResult.errorParam,
509
706
  errorMessage: compactResult.errorMessage,
510
- nextStep: tryStandalone ? "standalone-compact" : "native-summary",
707
+ nextStep: tryStandalone ? "standalone-compact" : scopedInput ? "stop-and-preserve" : "native-summary",
511
708
  }, config, ctx);
512
709
  if (tryStandalone && standaloneCompact) {
513
710
  if (event.signal?.aborted) return { outcome: "aborted" };
@@ -578,7 +775,7 @@ async function runResponsesNativeCompact(
578
775
  }
579
776
 
580
777
  const compaction = createNativeCompactionResult({
581
- firstKeptEntryId: event.preparation.firstKeptEntryId,
778
+ firstKeptEntryId: scopedInput?.firstKeptEntryId ?? event.preparation.firstKeptEntryId,
582
779
  tokensBefore: event.preparation.tokensBefore,
583
780
  details,
584
781
  });
@@ -607,12 +804,13 @@ async function runResponsesNativeCompact(
607
804
  requestExtras: extras ? Object.keys(extras) : [],
608
805
  compactResponseId: compactResult.compactResponseId,
609
806
  compactedItems: compactResult.compactedWindow.length,
610
- firstKeptEntryId: event.preparation.firstKeptEntryId,
807
+ firstKeptEntryId: compaction.firstKeptEntryId,
611
808
  },
612
809
  config,
613
810
  ctx,
614
811
  );
615
812
 
813
+ if (scopedInput && !scopedInput.isCurrent()) return { outcome: "aborted" };
616
814
  return { outcome: "success", compaction };
617
815
  }
618
816
 
@@ -744,6 +942,68 @@ async function runUnavailableContextCompaction(
744
942
  return { cancel: true };
745
943
  }
746
944
 
945
+ const ACTIVE_REMOTE_PREPARATION_REASONS = new Set([
946
+ "unsafe-compaction-window-boundary", "compaction-window-boundary-missing", "compaction-system-tool-head-lost",
947
+ "compaction-branch-changed", "compaction-source-projection-unavailable", "unverified-window-cleanup",
948
+ "compaction-owner-missing", "unsafe-compaction-retained-start", "compaction-owner-mismatch",
949
+ "compaction-continuity-unavailable", "compaction-operation-superseded", "compaction-tool-pair-mismatch",
950
+ "compaction-retained-context-mismatch", "compaction-wire-adaptation-failed", "compaction-boundary-not-found",
951
+ "first-kept-entry-not-found", "compaction-summary-not-found", "retained-context-mismatch", "unverified-compaction-input",
952
+ COMPACTION_CHECKPOINT_CONTEXT_EDITED, COMPACTION_CHECKPOINT_PROVENANCE_UNAVAILABLE,
953
+ COMPACTION_PROJECTED_CONTEXT_UNAVAILABLE, COMPACTION_PROJECTION_UNAVAILABLE, COMPACTION_SESSION_CONTEXT_UNAVAILABLE,
954
+ ]);
955
+
956
+ async function runActiveRemoteManualCompact(
957
+ event: SessionBeforeCompactEvent,
958
+ ctx: ExtensionContext,
959
+ dependencies: CompactionDependencies,
960
+ resolved: ResolvedToolkitConfig,
961
+ operation: RemoteManualCompactOperation | undefined,
962
+ ): Promise<{ cancel: true } | { compaction: CompactionResult<NativeCompactionDetails> }> {
963
+ const lifecycle = dependencies.remoteManualCompact;
964
+ const config = resolved.config.compaction;
965
+ const stop = (reason: string): { cancel: true } => {
966
+ // No exception text, provider body, auth or opaque content crosses this boundary.
967
+ lifecycle.cancel(operation);
968
+ writeDebugArtifact("compaction-event", {
969
+ event: "session_before_compact.remote-window-manual-stopped", reason, nextStep: "stop-and-preserve",
970
+ }, config, ctx);
971
+ notifyWarning(ctx, `Remote /compact stopped (${reason}); existing context preserved. No summary fallback or automatic continuation was started.`);
972
+ return { cancel: true };
973
+ };
974
+ const isCurrent = () => !!operation && lifecycle.current(operation, ctx);
975
+ try {
976
+ if (!operation || dependencies.manualCompact?.busy || !isCurrent()) return stop("compaction-operation-superseded");
977
+ // Producer policy and auth are resolved once from the initiating document.
978
+ if (resolved.format === "v2" && config.remoteCompactModel) {
979
+ const spec = parseModelSpec(config.remoteCompactModel);
980
+ if (spec) {
981
+ const producerPolicy = resolveToolkitConfig(resolved.snapshot, { provider: spec.provider, id: spec.modelId });
982
+ notifyConfigIssues(ctx, producerPolicy);
983
+ if (producerPolicy.invalidFeatures.includes("compatibility")) return stop("invalid-producer-compatibility");
984
+ }
985
+ }
986
+ const resolution = await resolveRemoteCompactionExecution(ctx, {
987
+ enabled: config.enabled, responsesApis: config.responsesApis, codexGatewayModels: compactGatewayModels(resolved),
988
+ }, config.remoteCompactModel);
989
+ if (!isCurrent()) return stop("compaction-operation-superseded");
990
+ if (!resolution.ok) return stop(resolution.reason);
991
+ const scopedEvent = { ...event, signal: operation.signal };
992
+ const input = await prepareActiveRemoteCompaction(scopedEvent, ctx, config, resolution.execution, dependencies, isCurrent);
993
+ if (!isCurrent()) return stop("compaction-operation-superseded");
994
+ const outcome = await runResponsesNativeCompact(scopedEvent, ctx, config, resolution.execution,
995
+ dependencies.remoteCompact, dependencies.projectCompactionContext, undefined, input);
996
+ if (!isCurrent()) return stop("compaction-operation-superseded");
997
+ if (outcome.outcome !== "success") return stop(outcome.outcome === "unprojected-input" ? outcome.reason
998
+ : outcome.outcome === "failed" ? outcome.remoteFailure?.reason ?? "remote-compaction-failed" : "aborted");
999
+ const result = lifecycle.arm(operation, ctx, outcome.compaction);
1000
+ return result ?? stop("compaction-operation-superseded");
1001
+ } catch (error) {
1002
+ return stop(error instanceof Error && ACTIVE_REMOTE_PREPARATION_REASONS.has(error.message)
1003
+ ? error.message : "remote-compaction-failed");
1004
+ }
1005
+ }
1006
+
747
1007
  async function handleSessionBeforeCompact(
748
1008
  event: SessionBeforeCompactEvent,
749
1009
  ctx: ExtensionContext,
@@ -786,16 +1046,33 @@ async function handleSessionBeforeCompact(
786
1046
  return { cancel: true };
787
1047
  }
788
1048
 
1049
+ // Capture intent BEFORE capability/auth awaits so selection/navigation/work
1050
+ // during resolution cannot silently become the owner of an older /compact.
1051
+ let manualOperation: RemoteManualCompactOperation | undefined;
1052
+ if (event.reason === "manual" && !dependencies.manualCompact?.isMaintenance && config.enabled && config.contextManagement === "remote") {
1053
+ try { manualOperation = dependencies.remoteManualCompact.begin(event, ctx, resolved); } catch { /* active route stops below */ }
1054
+ }
1055
+
789
1056
  // Active and inactive covered Codex consumers retain their existing routing.
790
1057
  const contextModel = config.enabled && config.contextManagement === "remote"
791
1058
  ? await resolveContextCapabilityModel(ctx)
792
1059
  : undefined;
1060
+ if (manualOperation && !dependencies.remoteManualCompact.current(manualOperation, ctx)) {
1061
+ dependencies.remoteManualCompact.cancel(manualOperation);
1062
+ return { cancel: true };
1063
+ }
793
1064
  if (isCodexContextModel(contextModel, config)) {
794
- if (await remoteContextActive(ctx, config, contextModel)) {
1065
+ const active = await remoteContextActive(ctx, config, contextModel);
1066
+ // New work may make a formerly active runtime look unavailable. That is
1067
+ // supersession, not permission to start the inactive-runtime fallback.
1068
+ if (manualOperation && !dependencies.remoteManualCompact.current(manualOperation, ctx)) {
1069
+ dependencies.remoteManualCompact.cancel(manualOperation);
1070
+ return { cancel: true };
1071
+ }
1072
+ if (active) {
795
1073
  try {
796
- if (event.reason === "manual" && dependencies.manualCompact && !dependencies.manualCompact.isMaintenance) {
797
- await dependencies.manualCompact.request(event, ctx, resolved);
798
- return { cancel: true };
1074
+ if (event.reason === "manual" && !dependencies.manualCompact?.isMaintenance) {
1075
+ return await runActiveRemoteManualCompact(event, ctx, dependencies, resolved, manualOperation);
799
1076
  }
800
1077
  return dependencies.contextWindows.prepareCompaction(event);
801
1078
  } catch {
@@ -804,6 +1081,7 @@ async function handleSessionBeforeCompact(
804
1081
  }
805
1082
  }
806
1083
 
1084
+ dependencies.remoteManualCompact.cancel(manualOperation);
807
1085
  const registrationState = getContextToolRegistrationState();
808
1086
  const failureReason = contextManagementFailureReason(registrationState);
809
1087
  if (failureReason !== "context-tools-excluded") {
@@ -819,11 +1097,28 @@ async function handleSessionBeforeCompact(
819
1097
  );
820
1098
  }
821
1099
 
1100
+ dependencies.remoteManualCompact.cancel(manualOperation);
822
1101
  try {
823
1102
  const detached = dependencies.contextWindows.prepareDetachedCompaction(event);
824
1103
  // A local consumer cannot summarize history owned by a Remote window,
825
1104
  // even after switching modes or disabling Toolkit compaction.
826
1105
  if (detached) return detached;
1106
+ // A boundary already persisted by an earlier detach still owns this session.
1107
+ // Retired Remote history is outside Pi's effective projection at that point, so
1108
+ // ordinary compaction may continue; record why it no longer detaches.
1109
+ const ownedIdentity = dependencies.contextWindows.currentIdentity();
1110
+ if (ownedIdentity) {
1111
+ writeDebugArtifact(
1112
+ "compaction-event",
1113
+ {
1114
+ event: "session_before_compact.detached-already-owned",
1115
+ windowId: ownedIdentity.currentWindowId,
1116
+ superseded: dependencies.contextWindows.hasSupersededWindow(),
1117
+ },
1118
+ config,
1119
+ ctx,
1120
+ );
1121
+ }
827
1122
  } catch {
828
1123
  notifyRemoteContextFailure(ctx, "malformed-window-state");
829
1124
  return { cancel: true };
@@ -1040,6 +1335,11 @@ async function handleContextInternal(
1040
1335
  // The latter still contains retired remote windows and can be inflated by
1041
1336
  // an upstream error even though this request only carries the live window.
1042
1337
  let projected = contextWindows.project(event.messages, "remote", false);
1338
+ if (contextWindows.isPreparingCompaction) {
1339
+ // Synthetic preparation is read-only. The caller owns the one strict
1340
+ // retained-copy match, then encrypted wire adaptation after all hooks.
1341
+ return { messages: projected };
1342
+ }
1043
1343
  // Projection may observe the exact queued rollover before Pi persists
1044
1344
  // it. Decide replay ownership from this request, not the old branch.
1045
1345
  const ownsWindow = contextWindows.hasEffectiveWindow() || !contextWindows.currentIdentity();
@@ -1200,10 +1500,11 @@ async function handleBeforeProviderRequest(
1200
1500
  : undefined;
1201
1501
  const remoteActive = config.contextManagement === "remote" && await remoteContextActive(ctx, config, contextModel);
1202
1502
  let remotePayload: unknown;
1503
+ let activeWindowPayload = false;
1203
1504
  if (remoteActive) {
1204
1505
  try {
1205
1506
  remotePayload = contextWindows.rewritePayload(event.payload, ctx);
1206
- if (contextWindows.hasEffectiveWindow() || !contextWindows.currentIdentity()) return remotePayload;
1507
+ activeWindowPayload = contextWindows.hasEffectiveWindow() || !contextWindows.currentIdentity();
1207
1508
  event = { ...event, payload: remotePayload };
1208
1509
  } catch {
1209
1510
  notifyRemoteContextFailure(ctx, "malformed-request-state");
@@ -1230,10 +1531,10 @@ async function handleBeforeProviderRequest(
1230
1531
 
1231
1532
  // Do not reintroduce a pre-window opaque checkpoint after local projection.
1232
1533
  // Request affinity/encrypted-output rewriting is reserved for active Remote.
1233
- if (contextWindows.hasEffectiveWindow()) return undefined;
1534
+ if (contextWindows.hasEffectiveWindow() && !activeWindowPayload) return undefined;
1234
1535
 
1235
1536
  const branchEntries = ctx.sessionManager.getBranch();
1236
- const inherited = config.contextManagement === "remote" && contextWindows.hasSupersededWindow()
1537
+ const inherited = !activeWindowPayload && config.contextManagement === "remote" && contextWindows.hasSupersededWindow()
1237
1538
  ? resolveLatestNativeCompactionEntry(branchEntries) : undefined;
1238
1539
  const inheritedCheckpoint = inherited?.ok ? inherited.entry : undefined;
1239
1540
  const resolution = await resolveNativeCompactionEnvironment(
@@ -1291,6 +1592,10 @@ async function handleBeforeProviderRequest(
1291
1592
  },
1292
1593
  );
1293
1594
 
1595
+ // Capture is pre-Web-Search metadata only. An effective Remote window still
1596
+ // owns this live payload and must NEVER enter ordinary opaque replay below.
1597
+ if (activeWindowPayload) return remotePayload;
1598
+
1294
1599
  const latestNativeCompaction = resolveLatestNativeCompactionEntry(branchEntries, {
1295
1600
  baseUrl: runtime.baseUrl,
1296
1601
  });
@@ -1406,6 +1711,7 @@ export default function registerCompactionExtension(
1406
1711
  loadHistoryNotesThreadHint(ctx, signal, gatewayModels)
1407
1712
  );
1408
1713
  const manualCompact = new ManagedManualCompact(pi, contextWindows);
1714
+ const remoteManualCompact = new RemoteManualCompact(pi, contextWindows);
1409
1715
  const dependencies: CompactionDependencies = {
1410
1716
  remoteCompact: executeRemoteV2Compaction,
1411
1717
  standaloneCompact: executeNativeCompaction,
@@ -1413,7 +1719,8 @@ export default function registerCompactionExtension(
1413
1719
  contextWindows,
1414
1720
  ...overrides,
1415
1721
  manualCompact,
1416
- loadConfig: () => manualCompact.snapshot ?? loadConfig(),
1722
+ remoteManualCompact,
1723
+ loadConfig: () => remoteManualCompact.snapshot ?? manualCompact.snapshot ?? loadConfig(),
1417
1724
  piContextHookPatch: overrides.piContextHookPatch ?? piContextHookPatch,
1418
1725
  };
1419
1726
  let tools!: ReturnType<typeof registerContextManagementTools>;
@@ -1472,6 +1779,7 @@ export default function registerCompactionExtension(
1472
1779
  }
1473
1780
  };
1474
1781
  pi.on("session_start", async (_event, ctx) => {
1782
+ remoteManualCompact.invalidate();
1475
1783
  const resolved = contextOperation(dependencies.loadConfig, ctx);
1476
1784
  const syncOutcome = await syncTools(ctx, ctx.model, { notifyWindowFailure: true }, resolved);
1477
1785
  await manualCompact.recover(ctx);
@@ -1541,12 +1849,28 @@ export default function registerCompactionExtension(
1541
1849
  manualCompact.guardRequest(ctx);
1542
1850
  return handleContext(event, ctx, pi, dependencies.loadConfig, contextWindows, remoteContextActive, getContextToolRegistrationState);
1543
1851
  });
1544
- pi.on("session_before_compact", (event, ctx) => handleSessionBeforeCompact(event, ctx, dependencies, remoteContextActive, getContextToolRegistrationState));
1545
- pi.on("session_compact", (_event, ctx) => contextWindows.synchronize(ctx));
1852
+ pi.on("session_before_compact", async (event, ctx) => {
1853
+ remoteManualCompact.invalidate();
1854
+ try {
1855
+ return await handleSessionBeforeCompact(event, ctx, dependencies, remoteContextActive, getContextToolRegistrationState);
1856
+ } catch {
1857
+ // Pi treats a rejected hook as absent and can invoke its implicit summary.
1858
+ // Unexpected preparation/service failures must never authorize that work.
1859
+ try { notifyWarning(ctx, "Compaction stopped; existing context preserved. Resume manually."); } catch { /* stale UI */ }
1860
+ return { cancel: true };
1861
+ }
1862
+ });
1863
+ pi.on("session_compact", (event, ctx) => {
1864
+ contextWindows.synchronize(ctx);
1865
+ if (event.reason === "manual" && event.fromExtension) remoteManualCompact.persisted(ctx);
1866
+ else remoteManualCompact.invalidate();
1867
+ });
1546
1868
  pi.on("session_compact_failed", async (event, ctx) => {
1869
+ remoteManualCompact.invalidate();
1547
1870
  if (event.reason === "manual" && !manualCompact.isMaintenance) await manualCompact.compactFailed(ctx);
1548
1871
  });
1549
1872
  pi.on("session_shutdown", async (_event, ctx) => {
1873
+ remoteManualCompact.invalidate();
1550
1874
  await manualCompact.shutdown(ctx);
1551
1875
  contextWindowReady = false;
1552
1876
  contextWindows.reset();
@@ -1558,6 +1882,7 @@ export default function registerCompactionExtension(
1558
1882
  await manualCompact.settled(ctx);
1559
1883
  });
1560
1884
  pi.on("model_select", async (event, ctx) => {
1885
+ remoteManualCompact.invalidate();
1561
1886
  manualCompact.modelSelected(event.model, ctx);
1562
1887
  // Switching into a covered model mid-session must open the window
1563
1888
  // lifecycle immediately: without an identity the request rewrite skips
@@ -1568,13 +1893,29 @@ export default function registerCompactionExtension(
1568
1893
  await syncTools(ctx, event.model, { notifyWindowFailure: true }, resolved);
1569
1894
  });
1570
1895
  pi.on("before_agent_start", async (_event, ctx) => {
1896
+ remoteManualCompact.invalidate();
1571
1897
  await syncTools(ctx, ctx.model, { notifyWindowFailure: true });
1572
1898
  });
1573
- pi.on("thinking_level_select", (event, ctx) => manualCompact.thinkingSelected(event.level, ctx));
1574
- pi.on("message_start", (event) => manualCompact.messageStarted(event.message));
1899
+ pi.on("agent_start", () => remoteManualCompact.invalidate());
1900
+ pi.on("input", () => { remoteManualCompact.invalidate(); });
1901
+ pi.on("thinking_level_select", (event, ctx) => {
1902
+ remoteManualCompact.invalidate();
1903
+ manualCompact.thinkingSelected(event.level, ctx);
1904
+ });
1905
+ pi.on("message_start", (event) => {
1906
+ remoteManualCompact.invalidate();
1907
+ manualCompact.messageStarted(event.message);
1908
+ });
1575
1909
  pi.on("turn_end", (_event, ctx) => manualCompact.turnEnded(ctx));
1576
- pi.on("tool_call", (event) => manualCompact.guardTool(event.toolName));
1910
+ pi.on("tool_call", (event) => {
1911
+ remoteManualCompact.invalidate();
1912
+ return manualCompact.guardTool(event.toolName);
1913
+ });
1914
+ pi.on("session_before_switch", () => remoteManualCompact.invalidate());
1915
+ pi.on("session_before_fork", () => remoteManualCompact.invalidate());
1916
+ pi.on("session_before_tree", () => remoteManualCompact.invalidate());
1577
1917
  pi.on("session_tree", async (_event, ctx) => {
1918
+ remoteManualCompact.invalidate();
1578
1919
  contextWindows.synchronize(ctx);
1579
1920
  await manualCompact.recover(ctx);
1580
1921
  await syncTools(ctx);