From 8269adaae84b48e870bf01c5ac340c2c3149fb28 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 5 Sep 2026 17:41:34 -0700 Subject: [PATCH 01/19] docs(provider-switch): plan quota-independent switching without source-side summarization The bulk switch exists for the moment a subscription window is exhausted, yet the transaction still asks the exhausted source for a live /compact or handoff turn whenever a Codex session has ever compacted or a Claude session exceeds Codex's budget. Codex keeps every pre-compaction record on disk and never decrypts its summary locally, Claude's summary is plaintext, and both targets absorb overflow with their own native compaction, so no source turn is needed. The decomposition, spec and plan record the design: raw history carry-over, a deterministic shrink ladder isolated in the parser, optional native compaction on arrival, a structural exhaustion signal, and the existing source path kept as an explicit opt-in with two hazard checks. Refs #821, #820, Juliusolsson05/agent-transcript-parser#24, Juliusolsson05/codex-headless#46 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01B882eBLjpecu4ibaSL7emu --- .../quota-independent-provider-switch.md | 301 +++ ...09-05-quota-independent-provider-switch.md | 1976 +++++++++++++++++ ...uota-independent-provider-switch-design.md | 264 +++ 3 files changed, 2541 insertions(+) create mode 100644 docs/decomposition/quota-independent-provider-switch.md create mode 100644 docs/superpowers/plans/2026-09-05-quota-independent-provider-switch.md create mode 100644 docs/superpowers/specs/2026-09-05-quota-independent-provider-switch-design.md diff --git a/docs/decomposition/quota-independent-provider-switch.md b/docs/decomposition/quota-independent-provider-switch.md new file mode 100644 index 00000000..f6142485 --- /dev/null +++ b/docs/decomposition/quota-independent-provider-switch.md @@ -0,0 +1,301 @@ +# Quota-independent provider switch + +Status: Decomposition written 2026-09-05, awaiting user approval before any +implementation stage starts. Feature issue: [#821](https://github.com/Juliusolsson05/agent-code/issues/821). +Hazard issue: [#820](https://github.com/Juliusolsson05/agent-code/issues/820). +Package issues: [agent-transcript-parser#24](https://github.com/Juliusolsson05/agent-transcript-parser/issues/24), +[codex-headless#46](https://github.com/Juliusolsson05/codex-headless/issues/46). +Design spec: `docs/superpowers/specs/2026-09-05-quota-independent-provider-switch-design.md`. +Implementation plan: `docs/superpowers/plans/2026-09-05-quota-independent-provider-switch.md`. + +## Why this decomposition applies + +The switch transaction spans a parser package (neutral conversation document, +capacity planning, native projectors), two headless runtime packages, the main +process transaction, and the renderer's bulk modal. Three providers must agree +on one projected transcript, and the case set (what real transcripts look like +when a provider is exhausted, how large they are, what an oversized shrink must +preserve) has never been enumerated from recordings. The 2026-06-24 bulk switch +design shipped with no tests and the 2026-09-03 compaction-memory fix was driven +by a production heap snapshot, which is exactly the forward-patching pattern this +method prevents. The staged-decomposition threshold applies. + +The user has explicitly rejected two shapes: speculative pre-summarization that +spends source tokens on a guess, and any custom "handmade compaction" prompt as a +product path. Every stage below is constrained by that. + +## A — what exists and is trusted + +| Artifact | Trusted responsibility | Not established by it | +|---|---|---| +| `packages/agent-transcript-parser/src/operations/contextBudget.ts` `planConversationContext` | Four outcomes: `ready`, `existing-compaction`, `requires-portable-handoff`, `requires-compaction`; character budget = tokens × effective% × 0.9 × 2.5 | Any outcome that avoids a source turn when native-only compaction exists or the effective history exceeds budget | +| `operations/compaction.ts` `describeLatestCompaction`, `compactionPortability`, `conversationAfterLatestPortableCompaction` | Claude carrier vs boundary placeholder, Codex encrypted = native-only | Rejecting a carrier whose text is a rate-limit message (#820) | +| `operations/contextBudget.ts` `fitConversationToCharacterBudget` | Lossy suffix fit at user boundaries with a synthetic compaction marker; refused for encrypted Codex by the host | Clearing tool results, indexing dropped prompts, any policy below the model budget | +| `claude/conversation/decode.ts`, `codex/conversation/decode.ts` | Claude boundary+carrier → one `compaction` entry (`summarySource: carrier`); Codex `compacted` → `compaction` (`summarySource: encrypted`); pre-compaction records stay as ordinary entries | Claude assistant records with `isApiErrorMessage: true` are decoded as assistant messages | +| `claude/project/nativeResume.ts`, `codex/project/nativeResume.ts` | Compaction entry → Claude boundary+carrier; foreign plaintext compaction → Codex developer handoff; encrypted reasoning demoted cross-provider; same-provider `compacted` preserved verbatim | Acceptance of cleared tool outputs and drop markers by the live CLIs (structural only) | +| `src/main/providerSwitch/switchProvider.ts` | Transaction order: read → plan → (compactSource) → project → write; `overflowPolicy: compact \| fail \| truncate` | Any plan that skips `compactSource` when a source turn is required | +| `src/main/providerSwitch/compactBeforeSwitch.ts` | `/compact` delivery, stat-gated compaction wait (#720), Codex/OpenCode handoff turns, 300 s timeout | Rejecting a rate-limit carrier; failing fast on a `rate_limit` record; any use against a *target* session | +| `src/main/providerSwitch/transcriptEngine.ts` | Adapters, target profiles (Claude `[1m]` → 1M, Codex from `config.toml` + `models_cache.json`, OpenCode 128k) | Nothing new needed; budgets are reused | +| `src/main/ipc/provider.ts` | Per-agent lock, per-agent native confirmation dialog, progress events | Batch-level confirmation | +| `src/renderer/src/workspace/hook/actions/providerSwitchCore.ts` | Wake source, translate, `replaceSession` pinned to the agent, progress, refusal while `processActive \|\| semantic.currentTurn` | Switching a pane that is idle because of a limit but still shows a provider wait banner | +| `src/renderer/src/workspace/hook/actions/bulkProviderSwitch.ts`, `features/workspace/ui/BulkProviderSwitchModal.tsx` | Sequential batch, remembered batch, direction and scope, mid-turn count | Exhaustion awareness, strategy labels, arrival compaction options | +| `src/main/usage/*` | Claude OAuth usage rows (`session`, `weekly_all`, `weekly_scoped`), Codex `wham/usage` primary/secondary windows, 30 s cache, severity ≥95 = critical | An "exhausted" derivation with scope (all-models vs family) and reset time | +| `packages/codex-headless/src/proxy/CodexResponsesAdapter.ts` `classifyResponseFailed` | `context_window_exceeded`, `quota_exceeded`, `usage_not_included`, `invalid_request`, `server_overloaded`, `retryable` | `usage_limit_reached` (falls to `retryable`, codex-headless#46) | +| `packages/claude-code-headless/src/parsers/ResumePromptParser.ts` + `conditions/resumePrompt.ts` | Detects "Resume from summary / Resume full session as-is / Don't ask me again" and exposes keystroke actions | Any automatic answer; the app surfaces it as a condition for the user | +| `packages/agent-transcript-parser/testing/live-resume-probe.mts` | Real resume through headless Claude/Codex with a marker prompt; capacity strategy report | Shrunk projections; arrival compaction | +| `packages/agent-transcript-parser/testing/corpus/*` + `fixtures/evidence/*` | Redacted observed fixtures with manifest schema; extraction from `~/.claude` and `~/.codex` | Fixtures for compacted Codex rollouts of realistic size, rate-limit records, oversized Claude sessions | + +Source facts the stages rely on (verified 2026-09-05 against the vendored +Claude Code snapshot, the vendored Codex at upstream `47ca4619be`, and local +transcripts): + +- Codex remote compaction is used for every OpenAI/ChatGPT-authenticated + session; local plaintext compaction is only for non-OpenAI providers. The + `compacted` payload has `message: ""`, `replacement_history` with plaintext + user messages plus one `compaction { encrypted_content }` item, and the + pre-compaction records remain earlier in the same rollout. No decryption code + exists in the client. Encrypted items are model-family-bound, not thread-bound. +- Codex persists `token_count.rate_limits` (`used_percent`, `resets_at`, + `rate_limit_reached_type`, `limit_id`, `limit_name`) in the default rollout + mode but does not persist `Error` events. Pre-turn auto-compaction runs from + the last token count at 90 percent of the window; remote compaction fails + hard when its input exceeds the window and has no local fallback. +- Claude Code does not retry a subscriber 429, persists an assistant record with + `error: "rate_limit"` and `isApiErrorMessage: true`, uses the main model for + compaction in the same rate-limit pool, only rejects summaries starting with + `API Error`, auto-compacts before the first turn of an oversized resume, and + retries `/compact` up to three times dropping oldest API-round groups. Family + limits (Opus/Sonnet/Fable) leave other families usable; session/weekly do not. +- OpenAI's `externalAgentConfig/import` (Codex app-server, installed 0.153.4 + supports it) narrates every Claude tool call into a tagged note, truncates + results to 4,000 characters, drops thinking and edit diffs, ignores Claude + compaction boundaries, and has no size handling. + +Census of local stores on 2026-09-05 (reality, not estimate): + +| Store | Files inspected | With native compaction | With `rate_limit` record | Over 2 MB | Largest | +|---|---|---|---|---|---| +| `~/.codex/sessions` (newest 300) | 300 | 146 | n/a (not persisted) | 172 | 147 MB | +| `~/.claude/projects` (newest 266 main files) | 266 | 27 | 5 | 76 | 53 MB | + +Configured targets: Claude `claude-fable-5-1[1m]` → planning budget 2,250,000 +characters; Codex `gpt-6-astra` 272k at 95 percent → 581,400 characters +(≈232k tokens at the parser's 2.5 characters per token). + +## D — the end state + +- Bulk or single switch never delivers a prompt to the source provider unless + the user explicitly enables "compact on source first", and that option is + disabled while the source is exhausted. +- Codex → Claude: raw history carried, encrypted markers dropped, strategy + labeled `raw`; if raw exceeds Claude's budget, deterministic shrink, labeled + `shrunk`. Optional native Claude `/compact` on arrival using Claude quota. +- Claude → Codex: existing plaintext summary plus raw tail when it fits + (`native`), otherwise deterministic shrink so the projection stays under + Codex's auto-compact limit (`shrunk`). Codex handles later compaction itself. +- OpenCode edges follow the same ladder; no OpenCode handoff turn by default. +- A rate-limit carrier is never accepted as a compaction; a `rate_limit` record + after `/compact` fails the opt-in path immediately; limit error records are + never projected as assistant text. +- The bulk modal reads exhaustion structurally, defaults the direction, offers + "switch model instead" for family-scoped limits, asks once per batch, and + reports per-agent strategy honestly. +- `docs/design/provider-switching.md` describes the new outcomes and the opt-in + status of source mutation. + +## Stages + +### Stage 0 — Evidence corpus (instrumentation, produces nothing visible) + +- **Produces:** redacted observed fixtures under + `packages/agent-transcript-parser/fixtures/evidence/observed-sequences/`: + `codex-sequence-compacted-once`, `codex-sequence-compacted-multi` (a rollout + with ≥3 `compacted` records), `codex-sequence-rate-limit-snapshot` (a + `token_count` with `rate_limit_reached_type` set, if one exists locally, + otherwise recorded during Stage 4 verification), `claude-sequence-rate-limit` + (assistant record with `error: rate_limit`), `claude-sequence-oversized` + (a transcript whose effective history exceeds 581,400 characters), and a + shape census `docs/decomposition/evidence/provider-switch/census.md` giving, + per fixture, bytes by entry kind (user, assistant text, reasoning, tool call + input, tool result output) so the shrink ladder thresholds come from measured + proportions rather than guesses. Also: whether any of the five local + rate-limit transcripts has a compact boundary after the error (#820 evidence). +- **Verified by:** the existing manifest schema check + (`fixtures/evidence/manifest.schema.json`), the corpus tests + (`testing/corpus/observedFixtures.corpus.test.ts`), and a review that no + path, id, or secret survives redaction. +- **Why separate:** every later test is built from these files. Writing the + shrink ladder first would encode the shapes that happened to be in context. +- **Reality check:** local `~/.codex/sessions` and `~/.claude/projects`, read + through the existing extractor with the existing redaction rules. + +### Stage 1 — Parser hazard fixes (#820, part of parser#24) + +- **Produces:** `compactionAvailability` returns `rejected` for a carrier or + boundary whose text starts with a Claude rate-limit prefix; `describeLatestCompaction` + exposes it; Claude decode emits `opaque` (`nativeType: "api_error"`) for + `isApiErrorMessage: true` assistant records. Tests against Stage 0 fixtures. +- **Verified by:** unit tests on the real `claude-sequence-rate-limit` fixture + and on a boundary+carrier pair whose carrier text is taken verbatim from that + record (documented as synthesized-from-real in the fixture manifest). +- **Why separate:** it must land before any host change so the opt-in source + path can never accept the hazard while the new default path is built. +- **Reality check:** real rate-limit record text; Claude Code source for the + prefix list (`services/rateLimitMessages.ts`). + +### Stage 2 — Parser shrink module and planner option (parser#24) + +- **Produces:** `packages/agent-transcript-parser/src/operations/shrink.ts` + with `stripNativeOnlyCompactions`, `clearToolResults`, `dropOldestTurns`, and + `shrinkConversationToBudget` (the ladder, returns `{ conversation, report }`); + `planConversationContext(..., { allowSourceTurns: false })` returning the new + outcomes `raw-history` and `shrunk` and throwing `unfittable`; projector + structural tests proving Claude and Codex accept cleared outputs and the drop + marker. +- **Verified by:** tests against Stage 0 fixtures asserting exact outcome kind, + chars before/after, what was cleared and dropped (counts and the dropped + prompt index), and that every surviving tool call still has its input intact. + Structural projection tests. No host involvement. +- **Why separate:** this is the hard part and the reconciliation of three + providers' notions of "fits". It gets one consumer (the planner) and one + report shape so the host cannot arbitrate sizes itself. +- **Reality check:** Stage 0 census proportions set the ladder thresholds; the + live probe (Stage 7) measures real token counts of a shrunk projection. + +### Stage 3 — Host transaction policy + +- **Produces:** `SwitchProviderRequest.contextPolicy { allowSourceTurns, compactOnArrival }`; + `switchProvider` never calls `compactSource` when `allowSourceTurns` is false; + new progress phase `shrinking`; `SwitchProviderResult.strategy` and + `shrinkSummary`; `compactBeforeSwitch` throws on `rejected` availability and + when a `rate_limit` opaque entry appears after the `/compact` baseline line. + `overflowPolicy` retained for compatibility and mapped: `truncate` → shrink + ladder with drop only. +- **Verified by:** `switchProvider.test.ts` extended with fake adapters fed by + Stage 0 conversations (decoded fixtures, not literals); `compactBeforeSwitch.test.ts` + gains the rejected-carrier and rate-limit-after-baseline cases. +- **Why separate:** the host owns transaction order and locks; the parser must + already be green so failures here are host failures. +- **Reality check:** decoded real fixtures; the #720 retention discipline stays + (no `ConversationDocument` held across awaits in wait loops). + +### Stage 4 — Exhaustion signal + +- **Produces:** pure `src/shared/usage/exhaustion.ts` deriving per-provider + `{ exhausted, scope: 'all-models' | 'model-family' | 'unknown', resetsAt, label }` + from `UsageProviderSnapshot`; codex-headless `usage_limit_reached` errorType + with `resetsAt`, `limitId`, `limitName` (codex-headless#46); renderer runtime + field `limitHit` set from Claude `rate_limit` opaque entries and Codex + `usage_limit_reached` api errors; exposed through the existing usage IPC. +- **Verified by:** `usageNormalize.test.ts`-style tests on real payload shapes + (the existing tests already carry real Claude and Codex payloads) and a + codex-headless unit test on a recorded `response.failed` body. +- **Why separate:** it is a read-only signal consumed by UI defaults and by the + opt-in gate; it must not become a hard gate inside the transaction. +- **Reality check:** real usage payloads already in the repo's tests; a + recorded Codex 429 body if one is captured in the proxy dumps under + `~/.config/agent-code/proxy` (check first), otherwise the body shape from + `codex-rs/codex-api/src/api_bridge.rs` with the fixture marked synthesized. + +### Stage 5 — Arrival compaction (Claude target) + +- **Produces:** IPC `session:compact-after-switch` owned by + `src/main/providerSwitch/compactOnArrival.ts`: waits for the new session's + ready state, answers a visible `claude.resume-prompt` with "Resume from + summary", otherwise delivers `/compact`, then reuses the Stage 3 wait against + the *target* session kind; emits progress on the new session id; failure is + reported, never fatal (the pane is already live with full history). +- **Verified by:** unit tests with a fake `SessionManager` for both branches; + a system-level recording of the resume prompt appearing on a projected + transcript (Stage 0 has none; capture during the Stage 7 live probe run). +- **Why separate:** it runs after pane replacement and touches conditions; + bundling it into `switchProvider` would move pane replacement before the + transcript write, which the design forbids. +- **Reality check:** the headless resume-prompt parser's observed screen + strings; live probe capture. + +### Stage 6 — Bulk modal and switch-core guard + +- **Produces:** exhaustion banner and direction default, "Compact on arrival" + and "Compact on source first" checkboxes with the stated defaults, "Switch + model instead" row for family-scoped limits, one batch confirmation replacing + the per-agent native dialog on the default path, per-agent strategy in the + batch summary and toast, and a `providerSwitchCore` guard that allows a + session with `limitHit` newer than its last turn start even if `processActive` + is still true. +- **Verified by:** renderer tests on the modal state machine and a recorded + Claude "Usage limit reached · continuing automatically" screen to settle how + `processActive` behaves under the auto-wait banner (Unknown 1). +- **Why separate:** UI policy sits on top of a transaction that is already + proven; the guard change is the one behavior that needs a recording. +- **Reality check:** recorded screen; real usage snapshot. + +### Stage 7 — Integration, design doc, probe, PR + +- **Produces:** `docs/design/provider-switching.md` updated; submodule pointer + bumps with lockfile resync; vendored Codex pointer at upstream main; live + probe run for one Codex → Claude `raw`, one Codex → Claude `shrunk`, one + Claude → Codex `shrunk`, report attached to the PR; OpenAI importer run on + the same Claude fixture as an oracle for message ordering only. +- **Verified by:** `npm run typecheck`, unit/system/renderer suites, the + package suites in their own repos, the probe report read for meaning. +- **Why separate:** merge gate. +- **Reality check:** real CLIs, real resumed sessions. + +## What is being isolated + +- **Shrink ladder**: `packages/agent-transcript-parser/src/operations/shrink.ts`. + Single consumer: `operations/contextBudget.ts`. Forbidden importers: every + projector, every decoder, every host file. It knows entry kinds and character + budgets, never provider names. +- **Arrival compaction**: `src/main/providerSwitch/compactOnArrival.ts`. Single + consumer: the IPC handler in `src/main/ipc/provider.ts`. Forbidden: renderer + code, `switchProvider.ts`. +- **Exhaustion derivation**: `src/shared/usage/exhaustion.ts`. Consumers: the + bulk modal and the usage IPC. Forbidden: `providerSwitch/*` (the transaction + never gates on it). + +## Unknowns + +1. How `processActive` and `semantic.currentTurn` behave while Claude Code + shows "Usage limit reached · continuing automatically at …". If they stay + true, every limited Claude agent is skipped by the bulk switch today. +2. Whether a shrunk projection's real token count matches the 2.5 + characters-per-token estimate closely enough to stay under Codex's 90 percent + auto-compact limit; measured by the probe. +3. Whether Codex semantically ignores a `custom_tool_call_output` whose output is + a placeholder, or treats it as an error; probe with a marker prompt that asks + for the last three tool results. +4. Whether Claude's resume dialog appears for a freshly projected transcript + (timestamps are copied from the source; the dialog needs >100k tokens and + >1 h since the last activity). Both branches are implemented regardless. +5. Whether the #820 hazard ever manifests on disk; Stage 0 checks the five local + rate-limit transcripts. +6. Whether a 1M Claude target rejects arrival with "Usage credits required for + 1M context" on this account; if so the exhaustion signal must include it. +7. OpenCode import size limits for shrunk envelopes; the 128k planning window + is conservative but unverified against `opencode import`. +8. Whether PR #810 (workspace hook isolation) lands first; if so + `bulkProviderSwitch.ts` wiring in `hook/index.ts` must be rebased, not merged + blindly. + +## Fixture plan + +- Stage 0 produces every transcript fixture through + `packages/agent-transcript-parser/testing/corpus/extract-observed-sequences.mts` + with the existing redaction and manifest. New cases are named above. +- Stage 4's Codex 429 body comes from a proxy dump if one exists, else from the + upstream source shape, labeled synthesized in the manifest. +- Stage 6's Claude auto-wait screen is captured with the app's "Save debug logs" + bundle or a session recording, stored under + `testing/fixtures/rendering-recordings/` per the rendering discipline. +- Stage 7's probe report is attached to the PR, not committed. + +## Ownership boundaries + +A5/A6 own #808–811 and #813–814 (subagent discovery, remote transport, worktree +reconciliation, pane runtime isolation); #701 owns picker identity; the external +operator toolkit (#812, merged) owns `main/control` and `externalControlMcp`. +This work does not touch those directories. The only shared file is +`src/renderer/src/workspace/hook/index.ts`, where the bulk actions are wired; +changes there are limited to passing new parameters through. diff --git a/docs/superpowers/plans/2026-09-05-quota-independent-provider-switch.md b/docs/superpowers/plans/2026-09-05-quota-independent-provider-switch.md new file mode 100644 index 00000000..35566682 --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-quota-independent-provider-switch.md @@ -0,0 +1,1976 @@ +# Quota-Independent Provider Switch Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make single and bulk provider switches complete without any live turn on the source provider, so agents can leave an exhausted Claude or Codex subscription window, while every lossy step is deterministic, reported, and handled by the target's own native compaction. + +**Architecture:** The parser gains an isolated shrink ladder and a planner option that never returns a source-turn outcome; the main-process transaction defaults to that option and adds an arrival compaction step for Claude targets; the usage service derives a structural exhaustion signal; the bulk modal reads it, asks once, and labels strategies. The existing source-side `/compact` path is preserved as an explicit opt-in with two hazard checks added. + +**Tech Stack:** TypeScript, Vitest 4 (parser `core`/`corpus` projects, app `unit`/`system`/`renderer` projects), Electron IPC, git submodules for `agent-transcript-parser` and `codex-headless`. + +**Spec:** `docs/superpowers/specs/2026-09-05-quota-independent-provider-switch-design.md` +**Decomposition:** `docs/decomposition/quota-independent-provider-switch.md` (Stage 0 must be approved and complete before Task 2 starts) +**Issues:** #821 (feature, `Fixes`), #820 (hazard, `Fixes`), agent-transcript-parser#24, codex-headless#46 + +## Global Constraints + +- Default `contextPolicy.allowSourceTurns` is `false`; the transaction must never call `runtime.compactSource` under that default. +- No custom summarization prompt is introduced. The only summarizers are the providers' own `/compact` paths. +- Character budget stays `tokens × effectivePercent × (1 − 0.1) × 2.5` from `budgetCharactersForContextTokens`; Codex projections must remain under Codex's 90 percent auto-compact limit, which the 0.9 reserve on a 95 percent effective window already guarantees (85.5 percent). +- Parser fixtures come from `testing/corpus/extract-observed-sequences.mts` output only. No transcript literals invented in tests; a synthesized fixture must be labeled in its manifest `normalization`. +- Wait loops keep scalars, never a `ConversationDocument`, across an `await` (#720 discipline). +- Commit subjects use Conventional Commits with a subsystem scope; every commit body that touches behavior explains why. +- Files owned by A5/A6 (#808–811, #813–814), #701, and the operator toolkit (`src/main/control`, `src/main/externalControlMcp`) are not modified. `src/renderer/src/workspace/hook/index.ts` changes only to pass new parameters. +- Do not merge. Open the PRs and stop. + +## File map + +| Repo | File | Responsibility | +|---|---|---| +| parser | `src/operations/estimate.ts` (new) | Character estimators and user-boundary predicate shared by budget and shrink | +| parser | `src/operations/shrink.ts` (new) | The shrink ladder and its report; only consumer is the planner | +| parser | `src/operations/contextBudget.ts` | `allowSourceTurns` option, `raw-history` and `shrunk` outcomes | +| parser | `src/operations/compaction.ts` | `rejected` availability, rate-limit prefixes, `findApiErrorAfterLine` | +| parser | `src/claude/conversation/decode.ts` | API-error assistant records become opaque | +| parser | `testing/engine/shrink.test.ts` (new), `testing/engine/contextBudget.test.ts`, `testing/engine/compaction.test.ts` (new), `testing/engine/nativeResumeProjection.test.ts` | Tests against Stage 0 fixtures | +| parser | `fixtures/evidence/observed-sequences/` | Stage 0 corpus | +| codex-headless | `src/proxy/responsesProxy.ts`, `src/proxy/CodexResponsesAdapter.ts`, `src/channels/types.ts`, `src/channels/SemanticChannel.ts` | HTTP-failure classification incl. `usage_limit_reached` | +| codex-headless | `src/proxy/CodexResponsesAdapter.httpFailure.test.ts` (new) | Recorded-style test | +| app | `src/main/providerSwitch/switchProvider.ts` | Policy, strategy, `shrinking` progress | +| app | `src/main/providerSwitch/compactBeforeSwitch.ts` | Hazard checks; wait loop generalized to any session | +| app | `src/main/providerSwitch/compactOnArrival.ts` (new) | Arrival compaction for Claude targets | +| app | `src/main/ipc/provider.ts` | Policy pass-through, batch confirmation flag, new IPC | +| app | `src/preload/api/provider.ts`, `src/preload/index.d.ts` | `compactAfterSwitch`, `contextPolicy` types | +| app | `src/shared/types/usage.ts`, `src/main/usage/claudeUsage.ts`, `src/main/usage/codexUsage.ts`, `src/main/usage/normalize.ts` | Row `scope` | +| app | `src/shared/usage/exhaustion.ts` (new), `src/shared/usage/exhaustion.test.ts` (new) | Pure exhaustion derivation | +| app | `src/renderer/src/session-runtime/state.ts`, `src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts` | `limitHit` runtime field | +| app | `src/renderer/src/workspace/hook/actions/providerSwitchCore.ts`, `bulkProviderSwitch.ts`, `hook/index.ts` | Policy plumbing, guard, strategy tally | +| app | `src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx` | Banner, checkboxes, one confirmation, strategy summary | +| app | `docs/design/provider-switching.md` | Updated contract | + +--- + +### Task 0: Evidence corpus (decomposition Stage 0) + +**Files:** +- Create: `packages/agent-transcript-parser/fixtures/evidence/observed-sequences/codex-sequence-compacted-once/{manifest.json,source.jsonl}` +- Create: `.../codex-sequence-compacted-multi/`, `.../codex-sequence-rate-limit-snapshot/`, `.../claude-sequence-rate-limit/`, `.../claude-sequence-oversized/` +- Create: `docs/decomposition/evidence/provider-switch/census.md` +- Modify: `packages/agent-transcript-parser/testing/corpus/extractObservedSequences.ts` (add the five relationships) + +**Interfaces:** +- Produces: five fixture directories loadable by `readFile(new URL('source.jsonl', directory))` exactly like the existing sequence cases, each with `manifest.caseId` equal to the directory name and `proves: ['wire-shape', 'classification']`. + +- [ ] **Step 1: Add the five relationships to the sequence extractor** + +Open `packages/agent-transcript-parser/testing/corpus/extractObservedSequences.ts` and locate the relationship table that defines `codex-sequence-compaction`. Add, in the same shape: + +```ts +{ + caseId: 'codex-sequence-compacted-once', + provider: 'codex', + // A rollout with exactly one `compacted` record: session_meta, every + // response_item before it, the compacted record, and every response_item + // after it. Kept whole because the shrink ladder must see real pre- and + // post-compaction proportions, not a two-record reduction. + select: file => countRecords(file, r => r.type === 'compacted') === 1, + keep: r => r.type === 'session_meta' || r.type === 'response_item' || r.type === 'compacted' || r.type === 'turn_context', +}, +{ + caseId: 'codex-sequence-compacted-multi', + provider: 'codex', + select: file => countRecords(file, r => r.type === 'compacted') >= 3, + keep: r => r.type === 'session_meta' || r.type === 'response_item' || r.type === 'compacted' || r.type === 'turn_context', +}, +{ + caseId: 'codex-sequence-rate-limit-snapshot', + provider: 'codex', + select: file => file.some(r => r.type === 'event_msg' && r.payload?.type === 'token_count' && r.payload?.rate_limits?.rate_limit_reached_type != null), + keep: r => r.type === 'session_meta' || (r.type === 'event_msg' && r.payload?.type === 'token_count'), +}, +{ + caseId: 'claude-sequence-rate-limit', + provider: 'claude', + select: file => file.some(r => r.type === 'assistant' && r.isApiErrorMessage === true && r.error === 'rate_limit'), + // The user prompt before the error and the error itself, plus any + // compact_boundary/isCompactSummary that follows: this is the #820 evidence. + keep: r => r.type === 'user' || r.type === 'assistant' || r.type === 'system', +}, +{ + caseId: 'claude-sequence-oversized', + provider: 'claude', + select: file => estimateSemanticCharacters(file) > 581_400, + keep: r => r.type === 'user' || r.type === 'assistant' || r.type === 'system', +}, +``` + +`countRecords` and `estimateSemanticCharacters` are small helpers in the same file: the first counts records matching a predicate; the second sums the length of `message.content` text blocks and `tool_result` content strings. If the extractor's existing helpers already cover these, reuse them. + +- [ ] **Step 2: Run the extractor against the local stores** + +Run from `packages/agent-transcript-parser`: + +```bash +npx tsx testing/corpus/extract-observed-sequences.mts --out fixtures/evidence/observed-sequences +``` + +Expected: five new directories. If `codex-sequence-rate-limit-snapshot` finds no candidate (no local rollout has `rate_limit_reached_type` set), leave it out and record that in the census; Task 5 captures one live. + +- [ ] **Step 3: Review redaction by hand** + +For each new `source.jsonl`, run: + +```bash +grep -c "/Users/" fixtures/evidence/observed-sequences//source.jsonl +``` + +Expected: `0` for every case. Also confirm no API key patterns (`sk-`, `Bearer`) survive. + +- [ ] **Step 4: Run the corpus and manifest checks** + +```bash +npm run test:corpus +``` + +Expected: PASS, including the manifest schema validation for the new cases. + +- [ ] **Step 5: Write the census** + +Create `docs/decomposition/evidence/provider-switch/census.md` with a table per fixture: bytes by entry kind after decoding (`message/user`, `message/assistant`, `reasoning`, `tool-call input`, `tool-result output`, `compaction`), total characters per `estimateConversationCharacters`, and the ratio of tool-result bytes to total. Use this one-off script (do not commit it): + +```ts +import { readFile } from 'node:fs/promises' +import { classifyClaudeDocument, classifyCodexDocument, decodeClaudeConversation, decodeCodexConversation, decodeJsonl } from '../src/index.js' +const text = await readFile(process.argv[2], 'utf8') +const doc = decodeJsonl(text) +const conversation = process.argv[3] === 'claude' + ? decodeClaudeConversation(classifyClaudeDocument(doc).records) + : decodeCodexConversation(classifyCodexDocument(doc).records) +const bytes: Record = {} +for (const entry of conversation.entries) { + const key = entry.kind === 'message' ? `message/${entry.role}` : entry.kind + const size = entry.kind === 'message' ? JSON.stringify(entry.content).length + : entry.kind === 'reasoning' ? entry.text.length + : entry.kind === 'tool-call' ? JSON.stringify(entry.input ?? '').length + : entry.kind === 'tool-result' ? JSON.stringify(entry.output ?? '').length + : entry.kind === 'compaction' ? entry.summary.length : 0 + bytes[key] = (bytes[key] ?? 0) + size +} +console.log(JSON.stringify(bytes, null, 2)) +``` + +Record in the census whether any of the five local Claude rate-limit transcripts has a `compact_boundary` after the error (the #820 question), with the answer yes or no per file (file names redacted to an index). + +- [ ] **Step 6: Commit (parser submodule)** + +```bash +cd packages/agent-transcript-parser +git checkout -b feat/quota-independent-context-planning +git add fixtures/evidence/observed-sequences testing/corpus/extractObservedSequences.ts +git commit -m "test(corpus): record compacted, rate-limited and oversized transcript sequences + +Refs Juliusolsson05/agent-transcript-parser#24" +``` + +And in the app worktree: + +```bash +git add docs/decomposition/evidence/provider-switch/census.md +git commit -m "docs(provider-switch): record transcript census for the shrink ladder + +Refs #821" +``` + +**Stop here and present the census to the user. Stage 0 is the approval gate for the ladder thresholds in Task 2.** + +--- + +### Task 1: Parser hazard fixes (#820, Stage 1) + +**Files:** +- Modify: `packages/agent-transcript-parser/src/operations/compaction.ts` +- Modify: `packages/agent-transcript-parser/src/claude/conversation/decode.ts:56-60` +- Create: `packages/agent-transcript-parser/testing/engine/compaction.test.ts` + +**Interfaces:** +- Produces: `CompactionAvailability` gains `'rejected'`; `CLAUDE_RATE_LIMIT_PREFIXES: readonly string[]`; `findApiErrorAfterLine(conversation, baselineLine): ConversationOpaque | null`; Claude decode emits `{ kind: 'opaque', nativeType: 'api_error' }` for `isApiErrorMessage: true` assistant records. + +- [ ] **Step 1: Write the failing tests** + +```ts +// testing/engine/compaction.test.ts +import { readFile } from 'node:fs/promises' +import { describe, expect, it } from 'vitest' +import { + classifyClaudeDocument, + decodeClaudeConversation, + decodeJsonl, + describeLatestCompaction, + findApiErrorAfterLine, +} from '../../src/index.js' + +const fixture = new URL('../../fixtures/evidence/observed-sequences/claude-sequence-rate-limit/source.jsonl', import.meta.url) + +async function loadRateLimitConversation() { + const raw = decodeJsonl(await readFile(fixture, 'utf8')) + return decodeClaudeConversation(classifyClaudeDocument(raw).records) +} + +describe('rate-limit records', () => { + it('decodes a Claude rate-limit API error as an opaque entry, never as assistant text', async () => { + const conversation = await loadRateLimitConversation() + const errors = conversation.entries.filter(entry => entry.kind === 'opaque' && entry.nativeType === 'api_error') + expect(errors.length).toBeGreaterThan(0) + const assistantText = conversation.entries + .filter(entry => entry.kind === 'message' && entry.role === 'assistant') + .flatMap(entry => entry.kind === 'message' ? entry.content : []) + .filter(content => content.kind === 'text') + .map(content => content.kind === 'text' ? content.text : '') + expect(assistantText.some(text => text.startsWith("You've hit your"))).toBe(false) + }) + + it('reports the first API error after a baseline line', async () => { + const conversation = await loadRateLimitConversation() + const found = findApiErrorAfterLine(conversation, -1) + expect(found?.kind).toBe('opaque') + expect(findApiErrorAfterLine(conversation, found!.source.line)).toBeNull() + }) + + it('rejects a compaction carrier whose text is a rate-limit message', async () => { + const conversation = await loadRateLimitConversation() + const error = findApiErrorAfterLine(conversation, -1)! + const text = String((error.source.raw.message as { content?: Array<{ text?: string }> })?.content?.[0]?.text ?? '') + // Synthesized from the real record's text: a boundary + carrier pair that + // Claude would write if it accepted this text as its summary (see #820). + const synthetic = { + ...conversation, + entries: [{ + kind: 'compaction' as const, + summary: text, + summarySource: 'carrier' as const, + timestamp: error.timestamp, + source: { ...error.source, line: error.source.line + 1 }, + }], + } + expect(describeLatestCompaction(synthetic)?.availability).toBe('rejected') + }) +}) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cd packages/agent-transcript-parser && npx vitest run testing/engine/compaction.test.ts` +Expected: FAIL — `findApiErrorAfterLine` is not exported; the first test finds zero opaque `api_error` entries. + +- [ ] **Step 3: Implement in `compaction.ts`** + +```ts +export type CompactionAvailability = 'portable' | 'native-only' | 'incomplete' | 'rejected' + +// WHY these prefixes are a parser constant: Claude Code writes its rate-limit +// message as an ordinary assistant record whose text starts with one of these +// (services/rateLimitMessages.ts RATE_LIMIT_ERROR_PREFIXES). Its own +// compaction only rejects summaries starting with "API Error", so a limit hit +// during /compact can plausibly persist this text as the summary. Any host +// that accepted such a carrier would switch with the history destroyed (#820). +export const CLAUDE_RATE_LIMIT_PREFIXES = [ + "You've hit your", + "You've used", + "You're now using extra usage", + "You're close to", + "You're out of extra usage", +] as const + +export function isRateLimitText(text: string): boolean { + const trimmed = text.trimStart() + return CLAUDE_RATE_LIMIT_PREFIXES.some(prefix => trimmed.startsWith(prefix)) +} + +export function findApiErrorAfterLine( + conversation: ConversationDocument, + baselineLine: number, +): ConversationOpaque | null { + for (const entry of conversation.entries) { + if (entry.kind !== 'opaque' || entry.nativeType !== 'api_error') continue + if (entry.source.line > baselineLine) return entry + } + return null +} +``` + +In `compactionAvailability`, before the existing placeholder check: + +```ts + if (isRateLimitText(entry.summary)) return 'rejected' +``` + +`conversationAfterLatestPortableCompaction` already returns the conversation unchanged for anything not `portable`, so `rejected` needs no further handling there. Add `ConversationOpaque` to the imports from `../conversation/types.js`. + +- [ ] **Step 4: Implement in `claude/conversation/decode.ts`** + +At the top of the `user-message || assistant-message` branch (line 56), before computing `role`: + +```ts + if (record.family === 'assistant-message' && record.raw.isApiErrorMessage === true) { + // WHY an API error is opaque rather than an assistant message: Claude + // persists "You've hit your session limit…" as an assistant-shaped + // record so the TUI can render it. It is not model output. Projecting + // it would carry a provider's billing message into another provider's + // history as if the model had said it, and a rate-limit text would + // become indistinguishable from a summary (#820). + entries.push({ kind: 'opaque', nativeType: 'api_error', timestamp, source }) + continue + } +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `cd packages/agent-transcript-parser && npx vitest run testing/engine/compaction.test.ts && npm run typecheck` +Expected: PASS; typecheck clean. + +- [ ] **Step 6: Run the parser suite once** + +Run: `cd packages/agent-transcript-parser && npm test` +Expected: PASS. If `nativeResumeProjection.test.ts` counted assistant entries from a fixture that contains an api error, update its expectation with a comment citing this task. + +- [ ] **Step 7: Commit** + +```bash +git add src/operations/compaction.ts src/claude/conversation/decode.ts testing/engine/compaction.test.ts +git commit -m "fix(compaction): reject rate-limit text as a summary and keep API errors out of history + +A Claude rate-limit message is persisted as an assistant record and is not +rejected by Claude's own compaction guard. Treat such a carrier as rejected +and decode API-error records as opaque so no host can project them. + +Refs Juliusolsson05/agent-transcript-parser#24, Juliusolsson05/agent-code#820" +``` + +--- + +### Task 2: Parser shrink ladder and planner option (Stage 2) + +**Files:** +- Create: `packages/agent-transcript-parser/src/operations/estimate.ts` +- Create: `packages/agent-transcript-parser/src/operations/shrink.ts` +- Modify: `packages/agent-transcript-parser/src/operations/contextBudget.ts` +- Modify: `packages/agent-transcript-parser/src/index.ts` (export `./operations/shrink.js`) +- Create: `packages/agent-transcript-parser/testing/engine/shrink.test.ts` +- Modify: `packages/agent-transcript-parser/testing/engine/contextBudget.test.ts` +- Modify: `packages/agent-transcript-parser/testing/engine/nativeResumeProjection.test.ts` + +**Interfaces:** +- Consumes: `describeLatestCompaction`, `conversationAfterLatestPortableCompaction`, `compactionAvailability` semantics from Task 1. +- Produces: + +```ts +// estimate.ts +export function estimateEntryCharacters(entry: ConversationEntry): number +export function estimateConversationCharacters(conversation: ConversationDocument): number +export function isSafeResumeBoundary(entry: ConversationEntry): boolean + +// shrink.ts +export interface ShrinkOptions { + keepRecentTurns?: number // default 3 + maxInputChars?: number // default 8_000 + maxIndexedPrompts?: number // default 40 + promptIndexChars?: number // default 200 +} +export interface ShrinkReport { + strippedCompactions: number + clearedResults: number + clearedChars: number + trimmedInputs: number + trimmedChars: number + droppedEntries: number + droppedTurns: number + promptIndexChars: number + estimatedCharactersBefore: number + estimatedCharactersAfter: number + budgetCharacters: number +} +export interface ShrinkResult { conversation: ConversationDocument; report: ShrinkReport } +export class ConversationUnfittableError extends Error { readonly report: ShrinkReport } +export function stripNativeOnlyCompactions(conversation): { conversation: ConversationDocument; stripped: number } +export function clearToolResults(conversation, budgetCharacters, options?): { conversation; cleared: number; clearedChars: number } +export function trimToolInputs(conversation, budgetCharacters, options?): { conversation; trimmed: number; trimmedChars: number } +export function dropOldestTurns(conversation, budgetCharacters, options?): { conversation; droppedEntries: number; droppedTurns: number; promptIndexChars: number; stillExceedsBudget: boolean } +export function shrinkConversationToBudget(conversation, budgetCharacters, options?): ShrinkResult + +// contextBudget.ts +export interface PlanConversationContextOptions { allowSourceTurns?: boolean; shrink?: ShrinkOptions } +export type ConversationContextPlan = /* existing four */ + | { kind: 'raw-history'; conversation; estimatedCharacters; budgetCharacters; strippedCompactions: number } + | { kind: 'shrunk'; conversation; estimatedCharacters; budgetCharacters; report: ShrinkReport } +export function planConversationContext(conversation, targetProvider, budgetCharacters, options?: PlanConversationContextOptions): ConversationContextPlan +``` + +- [ ] **Step 1: Extract the estimators** + +Move `estimateEntryCharacters`, `estimateConversationCharacters`, `printableLength`, `isSafeResumeBoundary`, `nextSafeBoundary`, `lastSafeBoundary` from `contextBudget.ts` into `estimate.ts` unchanged, and re-export `estimateConversationCharacters` from `contextBudget.ts` so the public surface is stable: + +```ts +export { estimateConversationCharacters } from './estimate.js' +``` + +Run: `npm run typecheck` — Expected: clean. + +- [ ] **Step 2: Write the failing shrink tests against real fixtures** + +```ts +// testing/engine/shrink.test.ts +import { readFile } from 'node:fs/promises' +import { describe, expect, it } from 'vitest' +import { + classifyClaudeDocument, classifyCodexDocument, + decodeClaudeConversation, decodeCodexConversation, decodeJsonl, + estimateConversationCharacters, + shrinkConversationToBudget, stripNativeOnlyCompactions, clearToolResults, dropOldestTurns, + ConversationUnfittableError, +} from '../../src/index.js' + +const sequences = new URL('../../fixtures/evidence/observed-sequences/', import.meta.url) +async function codex(caseId: string) { + const raw = decodeJsonl(await readFile(new URL(`${caseId}/source.jsonl`, sequences), 'utf8')) + return decodeCodexConversation(classifyCodexDocument(raw).records) +} +async function claude(caseId: string) { + const raw = decodeJsonl(await readFile(new URL(`${caseId}/source.jsonl`, sequences), 'utf8')) + return decodeClaudeConversation(classifyClaudeDocument(raw).records) +} + +describe('stripNativeOnlyCompactions', () => { + it('drops every encrypted Codex compaction and keeps the raw records around it', async () => { + const conversation = await codex('codex-sequence-compacted-multi') + const encrypted = conversation.entries.filter(e => e.kind === 'compaction').length + expect(encrypted).toBeGreaterThanOrEqual(3) + const { conversation: stripped, stripped: count } = stripNativeOnlyCompactions(conversation) + expect(count).toBe(encrypted) + expect(stripped.entries.some(e => e.kind === 'compaction')).toBe(false) + expect(stripped.entries.filter(e => e.kind === 'message').length) + .toBe(conversation.entries.filter(e => e.kind === 'message').length) + }) +}) + +describe('clearToolResults', () => { + it('clears oldest results first and never touches the last three user turns', async () => { + const conversation = await claude('claude-sequence-oversized') + const before = estimateConversationCharacters(conversation) + const { conversation: cleared, cleared: count, clearedChars } = clearToolResults(conversation, Math.floor(before / 2), { keepRecentTurns: 3 }) + expect(count).toBeGreaterThan(0) + expect(clearedChars).toBeGreaterThan(0) + // Every tool call keeps its input intact. + const inputsBefore = conversation.entries.filter(e => e.kind === 'tool-call').map(e => JSON.stringify(e.kind === 'tool-call' ? e.input : null)) + const inputsAfter = cleared.entries.filter(e => e.kind === 'tool-call').map(e => JSON.stringify(e.kind === 'tool-call' ? e.input : null)) + expect(inputsAfter).toEqual(inputsBefore) + // Results inside the last three user turns are untouched. + const userIndexes = cleared.entries.map((e, i) => e.kind === 'message' && e.role === 'user' ? i : -1).filter(i => i >= 0) + const protectedFrom = userIndexes.at(-3) ?? 0 + for (let i = protectedFrom; i < cleared.entries.length; i += 1) { + const entry = cleared.entries[i]! + if (entry.kind === 'tool-result') expect(entry.output).toEqual(conversation.entries[i]!.kind === 'tool-result' ? (conversation.entries[i] as typeof entry).output : entry.output) + } + }) +}) + +describe('dropOldestTurns', () => { + it('drops complete user turns from the front and indexes their prompts in the marker', async () => { + const conversation = await claude('claude-sequence-oversized') + const before = estimateConversationCharacters(conversation) + const result = dropOldestTurns(conversation, Math.floor(before / 4)) + expect(result.droppedTurns).toBeGreaterThan(0) + const marker = result.conversation.entries[0]! + expect(marker.kind).toBe('compaction') + expect(marker.kind === 'compaction' ? marker.summarySource : null).toBe('synthetic') + expect(marker.kind === 'compaction' ? marker.summary : '').toContain('earlier prompts') + expect(result.conversation.entries[1]?.kind === 'message' && result.conversation.entries[1].role).toBe('user') + }) +}) + +describe('shrinkConversationToBudget', () => { + it('reports each rung it used and lands under budget', async () => { + const conversation = await claude('claude-sequence-oversized') + const budget = 581_400 + const { conversation: shrunk, report } = shrinkConversationToBudget(conversation, budget) + expect(estimateConversationCharacters(shrunk)).toBeLessThanOrEqual(budget) + expect(report.estimatedCharactersAfter).toBeLessThanOrEqual(budget) + expect(report.estimatedCharactersBefore).toBeGreaterThan(budget) + expect(report.clearedResults + report.droppedEntries).toBeGreaterThan(0) + }) + + it('throws instead of emitting a fragment when the last turn alone exceeds the budget', async () => { + const conversation = await claude('claude-sequence-oversized') + expect(() => shrinkConversationToBudget(conversation, 200)).toThrow(ConversationUnfittableError) + }) +}) +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `npx vitest run testing/engine/shrink.test.ts` +Expected: FAIL — module `./operations/shrink.js` does not exist. + +- [ ] **Step 4: Implement `shrink.ts`** + +```ts +import type { + ConversationCompaction, ConversationDocument, ConversationEntry, ConversationToolResult, +} from '../conversation/types.js' +import { compactionAvailability } from './compaction.js' +import { estimateConversationCharacters, estimateEntryCharacters, isSafeResumeBoundary } from './estimate.js' + +// See docs/superpowers/specs/2026-09-05-quota-independent-provider-switch-design.md +// §"Shrink ladder". This module is the isolated hard part of quota-independent +// switching: it is the ONLY place that decides what a transcript loses when it +// must fit a smaller window without any model call. It knows entry kinds and +// character budgets. It never names a provider. Its only consumer is +// planConversationContext; projectors and hosts must not import it. + +export interface ShrinkOptions { + keepRecentTurns?: number + maxInputChars?: number + maxIndexedPrompts?: number + promptIndexChars?: number +} + +export interface ShrinkReport { + strippedCompactions: number + clearedResults: number + clearedChars: number + trimmedInputs: number + trimmedChars: number + droppedEntries: number + droppedTurns: number + promptIndexChars: number + estimatedCharactersBefore: number + estimatedCharactersAfter: number + budgetCharacters: number +} + +export interface ShrinkResult { + conversation: ConversationDocument + report: ShrinkReport +} + +export class ConversationUnfittableError extends Error { + readonly report: ShrinkReport + constructor(report: ShrinkReport) { + super(`Conversation cannot fit the target budget of ${report.budgetCharacters} characters: the final complete turn alone is ${report.estimatedCharactersAfter} characters.`) + this.name = 'ConversationUnfittableError' + this.report = report + } +} + +const DEFAULTS: Required = { + // WHY three: the census (docs/decomposition/evidence/provider-switch/census.md) + // is the source of truth for this value; three complete user turns is the + // smallest window that still contains "what I was just doing" for the + // sessions measured there. Replace with the census figure in Task 0's PR. + keepRecentTurns: 3, + maxInputChars: 8_000, + maxIndexedPrompts: 40, + promptIndexChars: 200, +} + +export function stripNativeOnlyCompactions( + conversation: ConversationDocument, +): { conversation: ConversationDocument; stripped: number } { + // WHY only native-only and rejected/incomplete entries are removed: a + // portable (plaintext) or synthetic compaction is real content the target + // can read. An encrypted or placeholder one carries nothing a foreign target + // can use, while the raw records it summarized are still present. + const entries = conversation.entries.filter(entry => ( + entry.kind !== 'compaction' || compactionAvailability(entry) === 'portable' + )) + return { + conversation: entries.length === conversation.entries.length ? conversation : { ...conversation, entries }, + stripped: conversation.entries.length - entries.length, + } +} + +function userTurnStarts(entries: readonly ConversationEntry[]): number[] { + const starts: number[] = [] + entries.forEach((entry, index) => { + if (entry.kind === 'message' && entry.role === 'user') starts.push(index) + }) + return starts +} + +function protectedFromIndex(entries: readonly ConversationEntry[], keepRecentTurns: number): number { + const starts = userTurnStarts(entries) + if (keepRecentTurns <= 0 || starts.length === 0) return entries.length + return starts[Math.max(0, starts.length - keepRecentTurns)] ?? entries.length +} + +function clearedPlaceholder(chars: number): string { + return `[tool output cleared during provider switch: ${chars} characters]` +} + +export function clearToolResults( + conversation: ConversationDocument, + budgetCharacters: number, + options: ShrinkOptions = {}, +): { conversation: ConversationDocument; cleared: number; clearedChars: number } { + const keepRecentTurns = options.keepRecentTurns ?? DEFAULTS.keepRecentTurns + const limit = protectedFromIndex(conversation.entries, keepRecentTurns) + let total = estimateConversationCharacters(conversation) + if (total <= budgetCharacters) return { conversation, cleared: 0, clearedChars: 0 } + const entries = [...conversation.entries] + let cleared = 0 + let clearedChars = 0 + for (let index = 0; index < limit && total > budgetCharacters; index += 1) { + const entry = entries[index]! + if (entry.kind !== 'tool-result') continue + const before = estimateEntryCharacters(entry) + const placeholder = clearedPlaceholder(before) + if (before <= placeholder.length) continue + const replaced: ConversationToolResult = { ...entry, output: placeholder } + entries[index] = replaced + const after = estimateEntryCharacters(replaced) + total -= before - after + cleared += 1 + clearedChars += before - after + } + return { conversation: cleared === 0 ? conversation : { ...conversation, entries }, cleared, clearedChars } +} + +export function trimToolInputs( + conversation: ConversationDocument, + budgetCharacters: number, + options: ShrinkOptions = {}, +): { conversation: ConversationDocument; trimmed: number; trimmedChars: number } { + const keepRecentTurns = options.keepRecentTurns ?? DEFAULTS.keepRecentTurns + const maxInputChars = options.maxInputChars ?? DEFAULTS.maxInputChars + const limit = protectedFromIndex(conversation.entries, keepRecentTurns) + let total = estimateConversationCharacters(conversation) + if (total <= budgetCharacters) return { conversation, trimmed: 0, trimmedChars: 0 } + const entries = [...conversation.entries] + let trimmed = 0 + let trimmedChars = 0 + for (let index = 0; index < limit && total > budgetCharacters; index += 1) { + const entry = entries[index]! + if (entry.kind !== 'tool-call') continue + const serialized = typeof entry.input === 'string' ? entry.input : JSON.stringify(entry.input ?? null) + if (serialized.length <= maxInputChars) continue + const before = estimateEntryCharacters(entry) + const replaced = { + ...entry, + input: `${serialized.slice(0, maxInputChars)}\n[tool input trimmed during provider switch: ${serialized.length - maxInputChars} characters omitted]`, + } + entries[index] = replaced + const after = estimateEntryCharacters(replaced) + total -= before - after + trimmed += 1 + trimmedChars += before - after + } + return { conversation: trimmed === 0 ? conversation : { ...conversation, entries }, trimmed, trimmedChars } +} + +function promptText(entry: ConversationEntry): string { + if (entry.kind !== 'message') return '' + return entry.content.filter(c => c.kind === 'text').map(c => c.kind === 'text' ? c.text : '').join(' ').replace(/\s+/g, ' ').trim() +} + +export function dropOldestTurns( + conversation: ConversationDocument, + budgetCharacters: number, + options: ShrinkOptions = {}, +): { conversation: ConversationDocument; droppedEntries: number; droppedTurns: number; promptIndexChars: number; stillExceedsBudget: boolean } { + const maxIndexedPrompts = options.maxIndexedPrompts ?? DEFAULTS.maxIndexedPrompts + const promptIndexChars = options.promptIndexChars ?? DEFAULTS.promptIndexChars + const costs = conversation.entries.map(estimateEntryCharacters) + const total = costs.reduce((a, b) => a + b, 0) + if (total <= budgetCharacters) { + return { conversation, droppedEntries: 0, droppedTurns: 0, promptIndexChars: 0, stillExceedsBudget: false } + } + // Walk backward accumulating complete turns; the first boundary at which the + // suffix no longer fits is excluded. Mirrors fitConversationToCharacterBudget. + let suffix = 0 + let startIndex = conversation.entries.length + for (let index = conversation.entries.length - 1; index >= 0; index -= 1) { + suffix += costs[index] ?? 0 + const entry = conversation.entries[index]! + if (!isSafeResumeBoundary(entry)) continue + if (suffix <= budgetCharacters) { startIndex = index; continue } + break + } + if (startIndex >= conversation.entries.length) { + // Even the final complete turn does not fit. Keep it whole and let the + // caller decide; never emit a fragment of a turn. + const last = [...conversation.entries].reverse().findIndex(isSafeResumeBoundary) + startIndex = last < 0 ? 0 : conversation.entries.length - 1 - last + } + if (startIndex <= 0) { + return { conversation, droppedEntries: 0, droppedTurns: 0, promptIndexChars: 0, stillExceedsBudget: true } + } + const dropped = conversation.entries.slice(0, startIndex) + const kept = conversation.entries.slice(startIndex) + const previousSummary = [...dropped].reverse().find( + (entry): entry is ConversationCompaction => entry.kind === 'compaction' && entry.summary.trim().length > 0, + ) + const prompts = dropped + .filter(entry => entry.kind === 'message' && entry.role === 'user') + .map(promptText) + .filter(text => text.length > 0) + const shown = prompts.slice(-maxIndexedPrompts) + const index = shown.map((text, i) => `${prompts.length - shown.length + i + 1}. ${text.slice(0, promptIndexChars)}`).join('\n') + const summary = [ + previousSummary?.summary, + `[Provider switch omitted ${dropped.length} earlier entries across ${prompts.length} user turns so the session fits the target model. The retained history begins at the next complete user turn. Earlier prompts, oldest first${prompts.length > shown.length ? ` (last ${shown.length} shown)` : ''}:\n${index}]`, + ].filter(Boolean).join('\n\n') + const marker: ConversationCompaction = { + kind: 'compaction', + summary, + summarySource: 'synthetic', + timestamp: kept[0]?.timestamp ?? dropped.at(-1)?.timestamp ?? null, + source: dropped.at(-1)?.source ?? kept[0]!.source, + } + const entries = [marker, ...kept] + const after = entries.reduce((sum, entry) => sum + estimateEntryCharacters(entry), 0) + return { + conversation: { ...conversation, entries }, + droppedEntries: dropped.length, + droppedTurns: prompts.length, + promptIndexChars: index.length, + stillExceedsBudget: after > budgetCharacters, + } +} + +export function shrinkConversationToBudget( + conversation: ConversationDocument, + budgetCharacters: number, + options: ShrinkOptions = {}, +): ShrinkResult { + const report: ShrinkReport = { + strippedCompactions: 0, clearedResults: 0, clearedChars: 0, trimmedInputs: 0, trimmedChars: 0, + droppedEntries: 0, droppedTurns: 0, promptIndexChars: 0, + estimatedCharactersBefore: estimateConversationCharacters(conversation), + estimatedCharactersAfter: 0, + budgetCharacters, + } + // Rung 1: encrypted and placeholder compactions carry nothing for a foreign + // target; the records they summarized are still here. + const stripped = stripNativeOnlyCompactions(conversation) + report.strippedCompactions = stripped.stripped + let current = stripped.conversation + // Rung 2: tool outputs are the bulk of coding sessions and the cheapest + // thing to lose; the model's own words and every edit input survive. + const cleared = clearToolResults(current, budgetCharacters, options) + report.clearedResults = cleared.cleared + report.clearedChars = cleared.clearedChars + current = cleared.conversation + // Rung 3: oversized inputs (whole-file writes) beyond a cap. + const trimmed = trimToolInputs(current, budgetCharacters, options) + report.trimmedInputs = trimmed.trimmed + report.trimmedChars = trimmed.trimmedChars + current = trimmed.conversation + // Rung 4: drop whole oldest turns, indexing their prompts in the marker. + const dropped = dropOldestTurns(current, budgetCharacters, options) + report.droppedEntries = dropped.droppedEntries + report.droppedTurns = dropped.droppedTurns + report.promptIndexChars = dropped.promptIndexChars + current = dropped.conversation + report.estimatedCharactersAfter = estimateConversationCharacters(current) + if (dropped.stillExceedsBudget || report.estimatedCharactersAfter > budgetCharacters) { + throw new ConversationUnfittableError(report) + } + return { conversation: current, report } +} +``` + +Add `export * from './operations/shrink.js'` to `src/index.ts`. + +- [ ] **Step 5: Run the shrink tests** + +Run: `npx vitest run testing/engine/shrink.test.ts` +Expected: PASS. + +- [ ] **Step 6: Write the failing planner tests** + +Append to `testing/engine/contextBudget.test.ts`: + +```ts +describe('planConversationContext without source turns', () => { + it('returns raw-history for a Codex session with encrypted compaction that fits a 1M Claude budget', async () => { + const conversation = await codex('codex-sequence-compacted-once') + const budget = budgetCharactersForContextTokens(1_000_000) + const plan = planConversationContext(conversation, 'claude', budget, { allowSourceTurns: false }) + expect(plan.kind).toBe('raw-history') + if (plan.kind !== 'raw-history') return + expect(plan.strippedCompactions).toBe(1) + expect(plan.conversation.entries.some(e => e.kind === 'compaction')).toBe(false) + }) + + it('returns shrunk with a report for an oversized Claude session targeting Codex', async () => { + const conversation = await claude('claude-sequence-oversized') + const budget = 581_400 + const plan = planConversationContext(conversation, 'codex', budget, { allowSourceTurns: false }) + expect(plan.kind).toBe('shrunk') + if (plan.kind !== 'shrunk') return + expect(plan.estimatedCharacters).toBeLessThanOrEqual(budget) + expect(plan.report.budgetCharacters).toBe(budget) + }) + + it('keeps the existing outcomes when source turns are allowed', async () => { + const conversation = await codex('codex-sequence-compacted-once') + const plan = planConversationContext(conversation, 'claude', budgetCharactersForContextTokens(1_000_000)) + expect(plan.kind).toBe('requires-portable-handoff') + }) +}) +``` + +Reuse the `codex`/`claude` fixture loaders from `shrink.test.ts` by moving them into `testing/engine/fixtureConversations.ts` and importing from both files. + +- [ ] **Step 7: Implement the planner option** + +In `contextBudget.ts`: + +```ts +import { shrinkConversationToBudget, stripNativeOnlyCompactions } from './shrink.js' +import type { ShrinkOptions, ShrinkReport } from './shrink.js' + +export interface PlanConversationContextOptions { + /** + * WHY this defaults to true: every existing caller relies on the four + * original outcomes, two of which instruct the host to run a live turn on + * the source. A host that cannot or will not spend source quota passes + * false and receives only outcomes it can execute alone. + */ + allowSourceTurns?: boolean + shrink?: ShrinkOptions +} +``` + +Extend the `ConversationContextPlan` union with the two new members from the Interfaces block, then at the top of `planConversationContext`: + +```ts + if (options.allowSourceTurns === false) { + return planWithoutSourceTurns(conversation, budgetCharacters, options.shrink) + } +``` + +and: + +```ts +function planWithoutSourceTurns( + conversation: ConversationDocument, + budgetCharacters: number, + shrink: ShrinkOptions | undefined, +): ConversationContextPlan { + const latest = describeLatestCompaction(conversation) + // A portable summary already replaces everything before it; slice first so + // stripping cannot resurrect records the source itself evicted. + const sliced = conversationAfterLatestPortableCompaction(conversation) + const stripped = stripNativeOnlyCompactions(sliced) + const estimatedCharacters = estimateConversationCharacters(stripped.conversation) + if (estimatedCharacters <= budgetCharacters) { + if (stripped.stripped > 0) { + return { kind: 'raw-history', conversation: stripped.conversation, estimatedCharacters, budgetCharacters, strippedCompactions: stripped.stripped } + } + return sliced === conversation + ? { kind: 'ready', conversation, estimatedCharacters, budgetCharacters } + : { kind: 'existing-compaction', conversation: sliced, estimatedCharacters, budgetCharacters, compactionSourceLine: latest!.entry.source.line } + } + const { conversation: shrunk, report } = shrinkConversationToBudget(stripped.conversation, budgetCharacters, shrink) + return { kind: 'shrunk', conversation: shrunk, estimatedCharacters: report.estimatedCharactersAfter, budgetCharacters, report } +} +``` + +`planConversationContext`'s signature gains `options: PlanConversationContextOptions = {}` as the fourth parameter. + +- [ ] **Step 8: Structural projection tests** + +Append to `testing/engine/nativeResumeProjection.test.ts`: + +```ts +it('projects a shrunk conversation into Codex and Claude native shapes without validation errors', async () => { + const conversation = await claude('claude-sequence-oversized') + const { conversation: shrunk } = shrinkConversationToBudget(conversation, 581_400) + const codex = codexNativeResumeProjector.projectNativeResume(shrunk, { + targetSessionId: '00000000-0000-4000-8000-000000000001', now: '2026-09-05T00:00:00.000Z', cwd: '/project', + cliVersion: '0.153.4', modelProvider: 'openai', model: 'gpt-6-astra', + }) + expect(codex.values.some(v => v.type === 'response_item' && (v.payload as { role?: string })?.role === 'developer')).toBe(true) + expect(codex.report.changes.filter(c => c.kind === 'dropped' && c.code.includes('tool')).length).toBe(0) + const claudeOut = claudeNativeResumeProjector.projectNativeResume(shrunk, { + targetSessionId: '00000000-0000-4000-8000-000000000002', now: '2026-09-05T00:00:00.000Z', cwd: '/project', + version: '2.1.261', model: 'claude-fable-5-1[1m]', + }) + expect(claudeOut.values.some(v => v.type === 'system' && v.subtype === 'compact_boundary')).toBe(true) + expect(claudeOut.values.some(v => (v as { isCompactSummary?: boolean }).isCompactSummary === true)).toBe(true) +}) +``` + +Adjust the projector option names to the ones already used in that test file. + +- [ ] **Step 9: Run the parser suite and typecheck** + +Run: `npm run typecheck && npm test` +Expected: PASS. + +- [ ] **Step 10: Commit** + +```bash +git add src/operations/estimate.ts src/operations/shrink.ts src/operations/contextBudget.ts src/index.ts testing/engine +git commit -m "feat(operations): plan context without source turns through a deterministic shrink ladder + +Hosts that cannot spend source quota pass allowSourceTurns:false and get +raw-history or shrunk instead of an instruction to run /compact on the +source. The ladder strips encrypted compactions, clears old tool outputs, +trims oversized inputs, then drops whole oldest turns with a prompt index; +it never emits a turn fragment. + +Refs Juliusolsson05/agent-transcript-parser#24" +``` + +Open the parser PR (`feat/quota-independent-context-planning`), title +`feat(operations): quota-independent context planning with deterministic shrink`, body per conventions, `Fixes #24`. Do not merge. + +--- + +### Task 3: codex-headless usage-limit classification (codex-headless#46, Stage 4 part) + +**Files:** +- Modify: `packages/codex-headless/src/proxy/responsesProxy.ts` (`streamUpstreamResponse`, the `kind: 'response'` emit) +- Modify: `packages/codex-headless/src/proxy/CodexResponsesAdapter.ts` (new `response` branch, `response-chunk` and `response-end` early exits, `classifyHttpFailure`) +- Modify: `packages/codex-headless/src/channels/types.ts:492-520` (`SemanticApiErrorEvent`) +- Modify: `packages/codex-headless/src/channels/SemanticChannel.ts:706-725` (`publishApiError`) +- Create: `packages/codex-headless/src/proxy/CodexResponsesAdapter.httpFailure.test.ts` + +**Interfaces:** +- Produces: `SemanticApiErrorEvent.errorType` gains `'usage_limit_reached' | 'rate_limited'`; new optional fields `resetsAt?: number` (unix seconds), `limitId?: string`, `limitName?: string`. The proxy `response` event gains `headers: Record` containing only `x-codex-active-limit`, `retry-after`, and headers matching `/^x-.+-(primary|secondary)-(used-percent|reset-after-seconds|window-minutes)$/` or `/^x-.+-limit-name$/`. + +- [ ] **Step 1: Write the failing recorded-style test** + +```ts +// src/proxy/CodexResponsesAdapter.httpFailure.test.ts +import { describe, expect, it, vi } from 'vitest' +import { createRecordedAdapterHarness } from './CodexResponsesAdapter.recorded.test.js' // reuse the harness factory the recorded test exports; if it is file-local, lift it into ./testing/adapterHarness.ts in this task + +// Body shape from codex-rs/codex-api/src/api_bridge.rs (usage_limit_reached +// branch). Marked SOURCE-DERIVED: no local proxy dump contained a 429 at the +// time of writing; replace with a recorded body when one is captured. +const USAGE_LIMIT_BODY = JSON.stringify({ + error: { + type: 'usage_limit_reached', + message: "You've hit your usage limit. Try again later.", + resets_at: 1788659183, + plan_type: 'pro', + }, +}) + +describe('HTTP failures on /responses', () => { + it('publishes usage_limit_reached with the active limit and reset time', () => { + const { proxy, semantic } = createRecordedAdapterHarness() + const published = vi.spyOn(semantic, 'publishApiError') + proxy.emit('event', { kind: 'request', requestId: 'r1', path: '/v1/responses', endpoint: 'responses' }) + proxy.emit('event', { + kind: 'response', requestId: 'r1', path: '/v1/responses', status: 429, + headers: { + 'x-codex-active-limit': 'codex', + 'x-codex-primary-used-percent': '100', + 'x-codex-secondary-used-percent': '42.5', + 'x-codex-limit-name': 'Codex', + }, + }) + proxy.emit('event', { kind: 'response-chunk', requestId: 'r1', path: '/v1/responses', size: USAGE_LIMIT_BODY.length, chunk: Buffer.from(USAGE_LIMIT_BODY) }) + proxy.emit('event', { kind: 'response-end', requestId: 'r1', path: '/v1/responses' }) + expect(published).toHaveBeenCalledWith(expect.objectContaining({ + errorType: 'usage_limit_reached', + status: 429, + resetsAt: 1788659183, + limitId: 'codex', + limitName: 'Codex', + })) + }) + + it('classifies a generic 429 without the usage-limit type as rate_limited', () => { + const { proxy, semantic } = createRecordedAdapterHarness() + const published = vi.spyOn(semantic, 'publishApiError') + proxy.emit('event', { kind: 'request', requestId: 'r2', path: '/v1/responses', endpoint: 'responses' }) + proxy.emit('event', { kind: 'response', requestId: 'r2', path: '/v1/responses', status: 429, headers: { 'retry-after': '20' } }) + proxy.emit('event', { kind: 'response-chunk', requestId: 'r2', path: '/v1/responses', size: 2, chunk: Buffer.from('{}') }) + proxy.emit('event', { kind: 'response-end', requestId: 'r2', path: '/v1/responses' }) + expect(published).toHaveBeenCalledWith(expect.objectContaining({ errorType: 'rate_limited', retryAfterMs: 20_000 })) + }) +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `cd packages/codex-headless && npx vitest run src/proxy/CodexResponsesAdapter.httpFailure.test.ts` +Expected: FAIL — no `publishApiError` call (the adapter ignores `kind: 'response'`). + +- [ ] **Step 3: Extend the proxy event** + +In `responsesProxy.ts` `streamUpstreamResponse`, replace the `this.emit('event', { kind: 'response', ... })` call with: + +```ts + this.emit('event', { + kind: 'response', + requestId, + path: originalUrl, + status: upstreamRes.status, + // WHY only rate-limit headers travel on the event: the adapter must + // classify a 429 body against the active limit pool, and the pool + // identity lives in headers, not the body (codex-api/src/api_bridge.rs + // reads x-codex-active-limit). Forwarding every header would put auth + // and request ids into the semantic stream for no consumer. + headers: pickRateLimitHeaders(upstreamRes.headers), + }) +``` + +and add: + +```ts +const RATE_LIMIT_HEADER = /^x-.+-(?:primary|secondary)-(?:used-percent|reset-after-seconds|window-minutes)$|^x-.+-limit-name$/ +export function pickRateLimitHeaders(headers: Headers): Record { + const picked: Record = {} + headers.forEach((value, key) => { + const lower = key.toLowerCase() + if (lower === 'x-codex-active-limit' || lower === 'retry-after' || RATE_LIMIT_HEADER.test(lower)) { + picked[lower] = value + } + }) + return picked +} +``` + +Add `headers?: Record` to the proxy's `response` event type where the event union is declared. + +- [ ] **Step 4: Handle HTTP failures in the adapter** + +Add to the flow record type: `httpFailure: { status: number; headers: Record; body: string } | null`, initialized `null` where flows are created. In `handleProxyEvent` add, before the `response-chunk` branch: + +```ts + if (kind === 'response') { + const status = typeof ev.status === 'number' ? ev.status : 0 + const requestId = typeof ev.requestId === 'string' ? ev.requestId : '' + const flow = this.findFlowByRequestId(requestId) + if (!flow || status < 400) return + // A non-2xx /responses reply is a JSON error document, not an SSE + // stream. Buffer it whole and classify at response-end; the SSE frame + // drain would otherwise see a body with no `data:` lines and publish + // nothing, which is how usage limits used to vanish into a timeout. + flow.httpFailure = { + status, + headers: isRecord(ev.headers) ? (ev.headers as Record) : {}, + body: '', + } + return + } +``` + +In the `response-chunk` branch, immediately after `flow.lastEventAt = Date.now()`: + +```ts + if (flow.httpFailure) { + flow.httpFailure.body += chunkEv.chunk.toString('utf8') + return + } +``` + +In the `response-end` branch, immediately after `flow.lastEventAt = Date.now()`: + +```ts + if (flow.httpFailure) { + const classified = classifyHttpFailure(flow.httpFailure) + this.headless.semantic.publishApiError({ + turnId: flow.responseId, + errorType: classified.errorType, + message: classified.message, + retryAfterMs: classified.retryAfterMs, + status: flow.httpFailure.status, + resetsAt: classified.resetsAt, + limitId: classified.limitId, + limitName: classified.limitName, + source: 'proxy', + }) + if (flow.turnOpened && flow.responseId) { + this.headless.semantic.finishTurn({ turnId: flow.responseId, fullText: flow.fullText || undefined, source: 'proxy', confidence: 'fallback' }) + } + if (this.activeFlowId === flow.flowId) this.activeFlowId = null + this.publishRequestTerminal(flow, 'failed', 'response-error') + this.flows.delete(flow.flowId) + return + } +``` + +And the classifier, next to `classifyResponseFailed`: + +```ts +/** Port of codex-api/src/api_bridge.rs (usage_limit_reached) plus the + * generic 429 fallback. Runs on buffered non-2xx bodies only. */ +function classifyHttpFailure(failure: { status: number; headers: Record; body: string }): { + errorType: SemanticApiErrorEvent['errorType'] + message: string + retryAfterMs?: number + resetsAt?: number + limitId?: string + limitName?: string +} { + let parsed: Record | null = null + try { parsed = asRecord(JSON.parse(failure.body)) } catch { parsed = null } + const error = parsed ? asRecord(parsed.error) : null + const type = error ? stringField(error, 'type') ?? '' : '' + const message = (error && stringField(error, 'message')) || `HTTP ${failure.status} from /responses` + const retryAfterSeconds = Number(failure.headers['retry-after']) + const retryAfterMs = Number.isFinite(retryAfterSeconds) ? retryAfterSeconds * 1000 : undefined + if (failure.status === 429 && type === 'usage_limit_reached') { + const limitId = failure.headers['x-codex-active-limit'] || 'codex' + const resets = error ? error.resets_at : undefined + return { + errorType: 'usage_limit_reached', + message, + retryAfterMs, + resetsAt: typeof resets === 'number' ? resets : undefined, + limitId, + limitName: failure.headers[`x-${limitId.replace(/_/g, '-')}-limit-name`] ?? failure.headers[`x-${limitId}-limit-name`], + } + } + if (failure.status === 429) return { errorType: 'rate_limited', message, retryAfterMs } + if (type === 'insufficient_quota') return { errorType: 'quota_exceeded', message } + const sse = classifyResponseFailed(parsed ?? undefined) + return { errorType: sse.errorType, message: sse.message, retryAfterMs: sse.retryAfterMs ?? retryAfterMs } +} +``` + +Extend `SemanticApiErrorEvent` in `channels/types.ts` with the two new `errorType` members and `resetsAt?: number; limitId?: string; limitName?: string`, and pass those three through `publishApiError` in `SemanticChannel.ts`. + +- [ ] **Step 5: Run tests and typecheck** + +Run: `cd packages/codex-headless && npx vitest run src/proxy/CodexResponsesAdapter.httpFailure.test.ts && npm run typecheck && npm run test:core` +Expected: PASS. + +- [ ] **Step 6: Commit (codex-headless submodule)** + +```bash +git checkout -b fix/usage-limit-classification +git add src/proxy src/channels +git commit -m "fix(proxy): classify usage_limit_reached and generic 429 replies as distinct api errors + +Non-2xx /responses replies are JSON documents, not SSE; buffer and +classify them so an exhausted subscription window is observable +structurally with its active limit pool and reset time. + +Fixes Juliusolsson05/codex-headless#46" +``` + +Open the codex-headless PR. Do not merge. + +--- + +### Task 4: Host switch policy and strategy (Stage 3) + +**Files:** +- Modify: `src/main/providerSwitch/switchProvider.ts` +- Modify: `src/main/providerSwitch/switchProvider.test.ts` +- Modify: `src/main/ipc/provider.ts` +- Modify: `src/preload/api/provider.ts` + +**Interfaces:** +- Consumes: `planConversationContext(..., { allowSourceTurns: false })`, `ShrinkReport` from Task 2 (after the parser submodule pointer is bumped in this worktree; see Task 9 Step 1 for the bump order, which must happen before this task compiles). +- Produces: + +```ts +export type SwitchContextPolicy = { allowSourceTurns: boolean; compactOnArrival: boolean } +export const DEFAULT_SWITCH_CONTEXT_POLICY: SwitchContextPolicy = { allowSourceTurns: false, compactOnArrival: false } +export type SwitchStrategy = 'native' | 'raw' | 'shrunk' +// SwitchProviderRequest gains: contextPolicy?: Partial; sourceCompactionConfirmed?: boolean +// SwitchProviderResult 'switched' gains: strategy: SwitchStrategy; shrinkSummary: string | null +// ProviderSwitchProgress.phase gains: 'shrinking' +export function describeShrink(report: ShrinkReport): string +``` + +- [ ] **Step 1: Write the failing tests** + +Append to `switchProvider.test.ts`, using the existing `mocks` and a conversation decoded from the Stage 0 fixture instead of the inline literal (add a loader that reads `packages/agent-transcript-parser/fixtures/evidence/observed-sequences/codex-sequence-compacted-once/source.jsonl` through the real parser; the transcript engine is mocked, the parser is not): + +```ts +describe('quota-independent policy', () => { + it('never asks for a source turn by default when the Codex source has encrypted compaction', async () => { + mocks.sourceRead.mockResolvedValue(await loadFixtureConversation('codex-sequence-compacted-once', 'codex')) + mocks.targetProfile.mockResolvedValue({ model: 'claude-fable-5-1[1m]', budgetCharacters: 2_250_000 }) + mocks.targetProject.mockReturnValue(claudeProjection) + mocks.targetWrite.mockResolvedValue('/claude/target.jsonl') + mocks.targetSessionId.mockReturnValue('target-session') + const compactSource = vi.fn() + const result = await switchProvider( + { sourceKind: 'codex', targetKind: 'claude', sourceProviderSessionId: 'src', cwd: '/project', sourceSessionId: 'local' }, + { compactSource }, + ) + expect(compactSource).not.toHaveBeenCalled() + expect(result).toMatchObject({ kind: 'switched', strategy: 'raw', shrinkSummary: null }) + const projected = mocks.targetProject.mock.calls[0]![0] + expect(projected.entries.some((e: { kind: string }) => e.kind === 'compaction')).toBe(false) + }) + + it('reports shrunk with a summary and a shrinking progress phase when the history exceeds the target', async () => { + mocks.sourceRead.mockResolvedValue(await loadFixtureConversation('claude-sequence-oversized', 'claude')) + mocks.targetProfile.mockResolvedValue({ model: 'gpt-6-astra', modelProvider: 'openai', budgetCharacters: 581_400 }) + mocks.targetProject.mockReturnValue(projection) + mocks.targetWrite.mockResolvedValue('/codex/target.jsonl') + mocks.targetSessionId.mockReturnValue('target-session') + const onProgress = vi.fn() + const result = await switchProvider( + { sourceKind: 'claude', targetKind: 'codex', sourceProviderSessionId: 'src', cwd: '/project', sourceSessionId: 'local' }, + { onProgress }, + ) + expect(result).toMatchObject({ kind: 'switched', strategy: 'shrunk' }) + expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ phase: 'shrinking' })) + expect((result as { shrinkSummary: string }).shrinkSummary).toMatch(/cleared|dropped/) + }) + + it('still runs the opt-in source path when allowSourceTurns is true', async () => { + mocks.sourceRead.mockResolvedValue(await loadFixtureConversation('codex-sequence-compacted-once', 'codex')) + mocks.targetProfile.mockResolvedValue({ model: 'claude-fable-5-1[1m]', budgetCharacters: 2_250_000 }) + const compactSource = vi.fn(async () => await loadFixtureConversation('claude-sequence-compaction', 'claude')) + mocks.targetProject.mockReturnValue(claudeProjection) + mocks.targetWrite.mockResolvedValue('/claude/target.jsonl') + mocks.targetSessionId.mockReturnValue('target-session') + await switchProvider( + { sourceKind: 'codex', targetKind: 'claude', sourceProviderSessionId: 'src', cwd: '/project', sourceSessionId: 'local', contextPolicy: { allowSourceTurns: true } }, + { compactSource }, + ) + expect(compactSource).toHaveBeenCalledOnce() + }) +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `NODE_ENV=test npx vitest run --project unit src/main/providerSwitch/switchProvider.test.ts` +Expected: FAIL — `strategy` is undefined and `compactSource` is called. + +- [ ] **Step 3: Implement the policy in `switchProvider.ts`** + +Replace the planning and overflow block with: + +```ts + const policy: SwitchContextPolicy = { ...DEFAULT_SWITCH_CONTEXT_POLICY, ...request.contextPolicy } + const targetProfile = await target.targetProfile(targetCwd) + let compactedBeforeSwitch = false + let truncatedBeforeSwitch = false + let strategy: SwitchStrategy = 'native' + let shrinkSummary: string | null = null + + if (!policy.allowSourceTurns || request.overflowPolicy === 'truncate') { + // WHY the source is never consulted on this path: the whole point of the + // policy is that the source may be out of quota. The parser returns only + // outcomes the host can execute alone; a ConversationUnfittableError is + // the single legitimate failure and it aborts before any write. + const plan = planConversationContext(conversation, targetKind, targetProfile.budgetCharacters, { allowSourceTurns: false }) + if (plan.kind === 'shrunk') { + strategy = 'shrunk' + shrinkSummary = describeShrink(plan.report) + truncatedBeforeSwitch = plan.report.droppedTurns > 0 + if (request.sourceSessionId) { + runtime.onProgress?.({ sourceSessionId: request.sourceSessionId, phase: 'shrinking', message: `History exceeds ${targetKind}; ${shrinkSummary}` }) + } + } else if (plan.kind === 'raw-history') { + strategy = 'raw' + } + conversation = plan.conversation + } else { + /* existing block: plan without the option, overflowPolicy 'fail', compactSource, re-plan, throw if still oversized — unchanged */ + } +``` + +The existing block's `if (!truncatedBeforeSwitch) conversation = plan.conversation` line moves inside the `else`. Add: + +```ts +export function describeShrink(report: ShrinkReport): string { + const parts: string[] = [] + if (report.strippedCompactions > 0) parts.push(`${report.strippedCompactions} encrypted compaction${report.strippedCompactions === 1 ? '' : 's'} dropped`) + if (report.clearedResults > 0) parts.push(`${report.clearedResults} tool outputs cleared`) + if (report.trimmedInputs > 0) parts.push(`${report.trimmedInputs} tool inputs trimmed`) + if (report.droppedTurns > 0) parts.push(`${report.droppedTurns} oldest turns dropped`) + const kb = (n: number) => `${Math.round(n / 1000)}k` + return `${parts.join(', ') || 'no changes'} (${kb(report.estimatedCharactersBefore)} → ${kb(report.estimatedCharactersAfter)} chars)` +} +``` + +Return `strategy` and `shrinkSummary` in the `switched` result. Add `'shrinking'` to `ProviderSwitchProgress.phase`. Add the `SwitchContextPolicy`, `DEFAULT_SWITCH_CONTEXT_POLICY`, `SwitchStrategy` exports and the two new request fields. + +- [ ] **Step 4: Pass the policy through IPC and preload** + +In `src/main/ipc/provider.ts`, add `contextPolicy?: Partial` and `sourceCompactionConfirmed?: boolean` to `params`, forward both to `switchProvider(params, ...)`, and gate the native dialog: + +```ts + if (plan.kind === 'requires-compaction' && !params.sourceCompactionConfirmed) { + /* existing dialog */ + } +``` + +In `src/preload/api/provider.ts`, add the same two optional params, add `strategy: 'native' | 'raw' | 'shrunk'` and `shrinkSummary: string | null` to the `switched` result type, and add `'shrinking'` to the progress phase union. + +- [ ] **Step 5: Run tests and typecheck** + +Run: `NODE_ENV=test npx vitest run --project unit src/main/providerSwitch && npx tsc -p tsconfig.node.json --pretty false` +Expected: PASS; no type errors. (The renderer half is type-checked in Task 8.) + +- [ ] **Step 6: Commit** + +```bash +git add src/main/providerSwitch/switchProvider.ts src/main/providerSwitch/switchProvider.test.ts src/main/ipc/provider.ts src/preload/api/provider.ts +git commit -m "feat(provider-switch): default to a transaction that never needs the source provider + +Refs #821" +``` + +--- + +### Task 5: Hazard checks and a wait loop that can watch any session (Stage 3) + +**Files:** +- Modify: `src/main/providerSwitch/compactBeforeSwitch.ts` +- Modify: `src/main/providerSwitch/compactBeforeSwitch.test.ts` + +**Interfaces:** +- Consumes: `findApiErrorAfterLine`, `describeLatestCompaction` (`rejected`) from Task 1. +- Produces: + +```ts +export type TranscriptWatchTarget = { sessionId: string; kind: AgentProviderKind; cwd: string; providerSessionId: string } +export async function waitForNewCompactionOn(manager: SessionManager, target: TranscriptWatchTarget, beforeFingerprint: string | null, baselineLine: number, select: (c: ConversationDocument) => T): Promise +export function latestSourceLine(conversation: ConversationDocument): number // now exported +``` + +- [ ] **Step 1: Write the failing tests** + +Append to `compactBeforeSwitch.test.ts` (same fake `manager`, `mocks.read` sequence pattern as the existing cases): + +```ts + it('fails immediately when the compaction carrier is a rate-limit message', async () => { + const rateLimited = await loadFixtureConversation('claude-sequence-rate-limit', 'claude') + const error = findApiErrorAfterLine(rateLimited, -1)! + const text = String((error.source.raw.message as { content?: Array<{ text?: string }> })?.content?.[0]?.text ?? '') + mocks.read + .mockResolvedValueOnce(baselineConversation) + .mockResolvedValueOnce({ ...baselineConversation, entries: [...baselineConversation.entries, { + kind: 'compaction', summary: text, summarySource: 'carrier', timestamp: null, + source: { provider: 'claude', line: 900, raw: {}, evidence: [] }, + }] }) + await expect(compactSourceBeforeSwitch(manager, claudeRequest, requiresCompactionPlan)).rejects.toThrow(/usage limit/) + expect(mocks.read).toHaveBeenCalledTimes(2) + }) + + it('fails immediately when a rate-limit error lands after /compact was sent', async () => { + const rateLimited = await loadFixtureConversation('claude-sequence-rate-limit', 'claude') + mocks.read + .mockResolvedValueOnce(baselineConversation) + .mockResolvedValueOnce({ ...baselineConversation, entries: [...baselineConversation.entries, ...rateLimited.entries.filter(e => e.kind === 'opaque' && e.nativeType === 'api_error').map(e => ({ ...e, source: { ...e.source, line: 901 } }))] }) + await expect(compactSourceBeforeSwitch(manager, claudeRequest, requiresCompactionPlan)).rejects.toThrow(/usage limit/) + }) +``` + +`baselineConversation`, `claudeRequest`, `requiresCompactionPlan` are the objects the existing Claude test already builds; extract them to module scope if they are inline. + +- [ ] **Step 2: Run to verify failure** + +Run: `NODE_ENV=test npx vitest run --project unit src/main/providerSwitch/compactBeforeSwitch.test.ts` +Expected: FAIL — the wait loop accepts the carrier and, in the second case, times out (the mocked timers make it fall through to the timeout error, not the usage-limit error). + +- [ ] **Step 3: Implement** + +In `compactBeforeSwitch.ts`: + +1. Export `latestSourceLine`. +2. Introduce `TranscriptWatchTarget` and refactor `pollSourceUntil` to take a `target: TranscriptWatchTarget` instead of `request`; `waitForNewCompaction` becomes a thin wrapper that builds the target from the request and adds `baselineLine`. Add the exported `waitForNewCompactionOn`. +3. In the compaction probe: + +```ts + }, conversation => { + const limitError = findApiErrorAfterLine(conversation, baselineLine) + if (limitError) { + throw new Error(`The ${target.kind} provider reported a usage limit instead of compacting; the switch was aborted before any pane was replaced.`) + } + const latest = describeLatestCompaction(conversation) + if (latest && latest.fingerprint !== beforeFingerprint) { + if (latest.availability === 'rejected') { + throw new Error(`The ${target.kind} provider wrote a usage-limit message where its compaction summary should be; the switch was aborted.`) + } + if (latest.availability !== 'incomplete') return { value: select(conversation) } + } + return null + }) +``` + +`pollSourceUntil` must let a thrown probe error propagate immediately (it currently only catches read errors inside `decodeOnce`; keep the probe call outside that try/catch). + +4. Capture `baselineLine` with `latestSourceLine` in the same `readSourceAs` call that captures `beforeFingerprint` (return both from one selector so the document is not decoded twice). + +- [ ] **Step 4: Run tests** + +Run: `NODE_ENV=test npx vitest run --project unit src/main/providerSwitch/compactBeforeSwitch.test.ts` +Expected: PASS, existing cases unchanged. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/providerSwitch/compactBeforeSwitch.ts src/main/providerSwitch/compactBeforeSwitch.test.ts +git commit -m "fix(provider-switch): abort source compaction on a usage limit instead of accepting its text + +Fixes #820" +``` + +--- + +### Task 6: Arrival compaction for Claude targets (Stage 5) + +**Files:** +- Create: `src/main/providerSwitch/compactOnArrival.ts` +- Create: `src/main/providerSwitch/compactOnArrival.test.ts` +- Modify: `src/main/ipc/provider.ts` (register `session:compact-after-switch`) +- Modify: `src/preload/api/provider.ts` (`compactAfterSwitch`) +- Modify: `src/renderer/src/workspace/hook/actions/providerSwitchCore.ts` (fire after `replaceSession`) + +**Interfaces:** +- Consumes: `waitForNewCompactionOn`, `latestSourceLine` (Task 5); `manager.getConditionsSnapshot`, `manager.write`, `manager.deliverPromptToAgent`, `manager.getSessionKind`. +- Produces: + +```ts +export type CompactOnArrivalRequest = { sessionId: string; targetKind: AgentProviderKind; cwd: string; providerSessionId: string } +export type CompactOnArrivalResult = { ok: true; via: 'resume-prompt' | 'compact-command' } | { ok: false; message: string } +export async function compactOnArrival(manager: SessionManager, request: CompactOnArrivalRequest, onProgress?: (p: ProviderSwitchProgress) => void): Promise +``` + +- [ ] **Step 1: Write the failing tests** + +```ts +// compactOnArrival.test.ts — same vi.mock pattern as compactBeforeSwitch.test.ts for the transcript engine and timers +describe('compactOnArrival', () => { + it('answers a visible Claude resume prompt with "Resume from summary" and waits for the carrier', async () => { + const write = vi.fn(() => true) + const manager = { + getSessionKind: vi.fn(() => 'claude'), + getConditionsSnapshot: vi.fn(() => ({ + provider: 'claude', ts: 1, + conditions: { 'claude.resume-prompt': { kind: 'claude.resume-prompt', state: { visible: true, selectedIndex: 1 }, actions: [] } }, + })), + write, + deliverPromptToAgent: vi.fn(), + } + mocks.read.mockResolvedValueOnce(rawConversation).mockResolvedValueOnce(compactedConversation) + const result = await compactOnArrival(manager as never, { sessionId: 'new', targetKind: 'claude', cwd: '/project', providerSessionId: 'target' }) + expect(write).toHaveBeenNthCalledWith(1, 'new', '\x1b[A') + expect(write).toHaveBeenNthCalledWith(2, 'new', '\r') + expect(manager.deliverPromptToAgent).not.toHaveBeenCalled() + expect(result).toEqual({ ok: true, via: 'resume-prompt' }) + }) + + it('delivers /compact when no resume prompt is visible', async () => { + const manager = { + getSessionKind: vi.fn(() => 'claude'), + getConditionsSnapshot: vi.fn(() => null), + write: vi.fn(() => true), + deliverPromptToAgent: vi.fn(async () => ({ ok: true })), + } + mocks.read.mockResolvedValueOnce(rawConversation).mockResolvedValueOnce(compactedConversation) + const result = await compactOnArrival(manager as never, { sessionId: 'new', targetKind: 'claude', cwd: '/project', providerSessionId: 'target' }) + expect(manager.deliverPromptToAgent).toHaveBeenCalledWith('new', '/compact') + expect(result).toEqual({ ok: true, via: 'compact-command' }) + }) + + it('reports rather than throws when the target is not Claude', async () => { + const manager = { getSessionKind: vi.fn(() => 'codex') } + const result = await compactOnArrival(manager as never, { sessionId: 'new', targetKind: 'codex', cwd: '/project', providerSessionId: 'target' }) + expect(result).toEqual({ ok: false, message: 'Arrival compaction is only implemented for Claude targets.' }) + }) +}) +``` + +`rawConversation` is the decoded `codex-sequence-compacted-once` fixture re-labeled with `sourceProvider: 'claude'` entries removed of compaction; `compactedConversation` is the decoded `claude-sequence-compaction` fixture (its carrier is portable). + +- [ ] **Step 2: Run to verify failure** + +Run: `NODE_ENV=test npx vitest run --project unit src/main/providerSwitch/compactOnArrival.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement** + +```ts +// src/main/providerSwitch/compactOnArrival.ts +// See docs/superpowers/specs/2026-09-05-quota-independent-provider-switch-design.md +// §"Arrival compaction". Runs AFTER the pane was replaced, on the new session, +// with the target's quota. Failure is reported, never fatal: the pane already +// holds the full history and the user can compact by hand. +import { setTimeout as delay } from 'node:timers/promises' +import type { SessionManager } from '@main/sessionManager.js' +import type { AgentProviderKind } from '@shared/types/providerKind.js' +import { conversationAfterLatestPortableCompaction, describeLatestCompaction } from 'agent-transcript-parser' +import { getHostTranscriptAdapter } from '@main/providerSwitch/transcriptEngine.js' +import { latestSourceLine, waitForNewCompactionOn } from '@main/providerSwitch/compactBeforeSwitch.js' +import type { ProviderSwitchProgress } from '@main/providerSwitch/switchProvider.js' + +export type CompactOnArrivalRequest = { sessionId: string; targetKind: AgentProviderKind; cwd: string; providerSessionId: string } +export type CompactOnArrivalResult = { ok: true; via: 'resume-prompt' | 'compact-command' } | { ok: false; message: string } + +// WHY a bounded wait for the resume prompt: Claude shows it only after the +// transcript is restored, for sessions over ~100k tokens that were idle over +// an hour. A projected transcript copies the source's timestamps, so a parked +// agent usually qualifies and a just-active one does not. Ten seconds covers +// restore on the largest local transcripts (53 MB) without stalling the batch. +const RESUME_PROMPT_WAIT_MS = 10_000 +const RESUME_PROMPT_POLL_MS = 250 +const UP_ARROW = '\x1b[A' + +export async function compactOnArrival( + manager: SessionManager, + request: CompactOnArrivalRequest, + onProgress?: (progress: ProviderSwitchProgress) => void, +): Promise { + if (request.targetKind !== 'claude') { + return { ok: false, message: 'Arrival compaction is only implemented for Claude targets.' } + } + if (manager.getSessionKind(request.sessionId) !== 'claude') { + return { ok: false, message: 'The new pane is not a live Claude session.' } + } + const adapter = getHostTranscriptAdapter('claude') + const baseline = await adapter.read(request.cwd, request.providerSessionId).then(conversation => ({ + fingerprint: describeLatestCompaction(conversation)?.fingerprint ?? null, + line: latestSourceLine(conversation), + })) + const target = { sessionId: request.sessionId, kind: 'claude' as const, cwd: request.cwd, providerSessionId: request.providerSessionId } + onProgress?.({ sourceSessionId: request.sessionId, phase: 'compacting', message: 'Compacting the imported history with Claude…' }) + try { + const answered = await answerResumePrompt(manager, request.sessionId) + if (!answered) { + const delivery = await manager.deliverPromptToAgent(request.sessionId, '/compact') + if (!delivery.ok) return { ok: false, message: `Claude did not accept /compact: ${delivery.message}` } + } + await waitForNewCompactionOn(manager, target, baseline.fingerprint, baseline.line, conversationAfterLatestPortableCompaction) + return { ok: true, via: answered ? 'resume-prompt' : 'compact-command' } + } catch (error) { + return { ok: false, message: error instanceof Error ? error.message : String(error) } + } +} + +async function answerResumePrompt(manager: SessionManager, sessionId: string): Promise { + const deadline = Date.now() + RESUME_PROMPT_WAIT_MS + while (Date.now() < deadline) { + const snapshot = manager.getConditionsSnapshot(sessionId) + const record = snapshot?.conditions['claude.resume-prompt'] + const state = record?.state as { visible?: boolean; selectedIndex?: number } | undefined + if (state?.visible) { + // Option 1 is "Resume from summary (recommended)". The cursor defaults + // to option 2; move it up by the observed index, then confirm. The + // headless module deliberately exposes only confirm/cancel keystrokes + // and leaves cursor movement to the caller. + const moves = Math.max(0, state.selectedIndex ?? 1) + for (let i = 0; i < moves; i += 1) manager.write(sessionId, UP_ARROW) + manager.write(sessionId, '\r') + return true + } + await delay(RESUME_PROMPT_POLL_MS) + } + return false +} +``` + +Register the IPC in `provider.ts`: + +```ts + const arrivalsInFlight = new Set() + ipcMain.handle('session:compact-after-switch', async (_evt, params: CompactOnArrivalRequest) => { + if (arrivalsInFlight.has(params.sessionId)) return { ok: false, message: 'Arrival compaction already running.' } + arrivalsInFlight.add(params.sessionId) + try { + return await compactOnArrival(manager, params, progress => { + if (!_evt.sender.isDestroyed()) _evt.sender.send('session:provider-switch-progress', progress) + }) + } finally { + arrivalsInFlight.delete(params.sessionId) + } + }) +``` + +Preload: `compactAfterSwitch: (params: CompactOnArrivalRequest) => Promise`. + +Renderer (`providerSwitchCore.ts`), after the successful `replaceSession` on the transcript-backed branch: + +```ts + if (params.contextPolicy?.compactOnArrival && result.targetKind === 'claude') { + // Fire-and-forget on purpose: a batch of twenty agents must not + // serialize twenty Claude compactions. Progress lands on the new pane + // through the existing provider-switch progress channel. + void window.api.compactAfterSwitch({ + sessionId: newSessionId, + targetKind: result.targetKind, + cwd: meta.cwd, + providerSessionId: result.targetProviderSessionId, + }).then(outcome => { + if (!outcome.ok) params.onArrivalFailure?.(outcome.message) + }) + } +``` + +The progress subscription in `switchAgentProvider` currently filters on `event.sourceSessionId !== sessionId`; register a second, session-scoped subscription for `newSessionId` that writes `runtime.providerSwitch` until a `null` phase or the arrival promise settles. + +- [ ] **Step 4: Run tests and both typechecks** + +Run: `NODE_ENV=test npx vitest run --project unit src/main/providerSwitch && npx tsc -p tsconfig.node.json --pretty false && npx tsc -p tsconfig.web.json --pretty false` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/providerSwitch/compactOnArrival.ts src/main/providerSwitch/compactOnArrival.test.ts src/main/ipc/provider.ts src/preload/api/provider.ts src/renderer/src/workspace/hook/actions/providerSwitchCore.ts +git commit -m "feat(provider-switch): compact imported history on arrival with the target's own /compact + +Refs #821" +``` + +--- + +### Task 7: Exhaustion signal (Stage 4) + +**Files:** +- Modify: `src/shared/types/usage.ts` (`UsageLimitRow.scope`) +- Modify: `src/main/usage/normalize.ts:64-80` (`makeUsageRow` accepts `scope`) +- Modify: `src/main/usage/claudeUsage.ts`, `src/main/usage/codexUsage.ts` +- Create: `src/shared/usage/exhaustion.ts`, `src/shared/usage/exhaustion.test.ts` +- Modify: `src/main/usage/usageNormalize.test.ts` + +**Interfaces:** +- Produces: + +```ts +export type UsageLimitScope = 'all-models' | 'model-family' | 'unknown' +// UsageLimitRow gains: scope: UsageLimitScope +export type ProviderExhaustion = { provider: UsageProviderKind; exhausted: boolean; scope: UsageLimitScope; resetsAt: string | null; label: string } +export function deriveProviderExhaustion(snapshot: UsageProviderSnapshot): ProviderExhaustion +``` + +- [ ] **Step 1: Write the failing tests** + +In `usageNormalize.test.ts`, extend the existing real-payload cases: + +```ts + it('scopes Claude rows: session and weekly_all are all-models, weekly_scoped is model-family', () => { + const rows = normalizeClaudeUsagePayload(REAL_CLAUDE_PAYLOAD).rows + expect(rows.find(r => r.label === 'Current session')?.scope).toBe('all-models') + expect(rows.find(r => r.label === 'Current week (all models)')?.scope).toBe('all-models') + expect(rows.find(r => r.label.startsWith('Current week ('))?.scope ?? 'model-family').toBe('model-family') + }) + it('scopes Codex rows: the main rate_limit is all-models, additional limits are model-family', () => { + const rows = normalizeCodexUsagePayload(REAL_CODEX_PAYLOAD).rows + expect(rows.filter(r => r.id.startsWith('codex-')).every(r => r.scope === 'all-models')).toBe(true) + }) +``` + +`src/shared/usage/exhaustion.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { deriveProviderExhaustion } from './exhaustion.js' +const row = (o: Partial) => ({ + id: 'x', label: 'x', percent: 0, severity: 'normal' as const, resetsAt: null, active: true, detail: null, scope: 'all-models' as const, ...o, +}) +const ok = (rows: ReturnType[]) => ({ provider: 'claude' as const, status: 'ok' as const, sourceLabel: 's', plan: null, rows, spend: null, extraUsage: null, credits: null }) +describe('deriveProviderExhaustion', () => { + it('is exhausted with all-models scope when a shared window is at 100', () => { + expect(deriveProviderExhaustion(ok([row({ label: 'Current session', percent: 100, resetsAt: '2026-09-05T16:00:00Z' })]))) + .toEqual({ provider: 'claude', exhausted: true, scope: 'all-models', resetsAt: '2026-09-05T16:00:00Z', label: 'Current session' }) + }) + it('is exhausted with model-family scope when only a scoped weekly row is at 100', () => { + const result = deriveProviderExhaustion(ok([row({ label: 'Current week (Fable)', percent: 100, scope: 'model-family' }), row({ label: 'Current session', percent: 40 })])) + expect(result.exhausted).toBe(true) + expect(result.scope).toBe('model-family') + }) + it('is not exhausted below 100 and unknown on error snapshots', () => { + expect(deriveProviderExhaustion(ok([row({ percent: 99.4 })])).exhausted).toBe(false) + expect(deriveProviderExhaustion({ provider: 'codex', status: 'error', sourceLabel: 's', message: 'boom' })).toMatchObject({ exhausted: false, scope: 'unknown' }) + }) +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `NODE_ENV=test npx vitest run --project unit src/main/usage src/shared/usage` +Expected: FAIL — `scope` undefined; module missing. + +- [ ] **Step 3: Implement** + +`usage.ts`: add `UsageLimitScope` and `scope: UsageLimitScope` on `UsageLimitRow`. `normalize.ts` `makeUsageRow`: accept `scope?: UsageLimitScope`, default `'unknown'`, copy through. `claudeUsage.ts`: in the rows map, compute `scope` from `stringOrNull(limit.kind)`: `session` and `weekly_all` → `'all-models'`, `weekly_scoped` → `'model-family'`, else `'unknown'`, and pass it. `codexUsage.ts`: `codexRowsFromRateLimit` gains a `scope` parameter; the main `rate_limit` call passes `'all-models'`, the `additional_rate_limits` loop passes `'model-family'`, `limits` entries pass `'unknown'`. + +```ts +// src/shared/usage/exhaustion.ts +import type { UsageLimitRow, UsageLimitScope, UsageProviderKind, UsageProviderSnapshot } from '@shared/types/usage.js' + +export type ProviderExhaustion = { + provider: UsageProviderKind + exhausted: boolean + scope: UsageLimitScope + resetsAt: string | null + label: string +} + +// WHY 100 and not the 95 "critical" threshold: severity colors a header; this +// value gates defaults in a modal that moves agents. A window at 96 percent +// still accepts turns. Only a window the provider reports as fully used is +// treated as exhausted, and the modal always lets the user override. +const EXHAUSTED_PERCENT = 100 + +export function deriveProviderExhaustion(snapshot: UsageProviderSnapshot): ProviderExhaustion { + if (snapshot.status !== 'ok') { + return { provider: snapshot.provider, exhausted: false, scope: 'unknown', resetsAt: null, label: snapshot.message } + } + const hit = (scope: UsageLimitScope): UsageLimitRow | undefined => snapshot.rows.find(row => ( + row.active && row.scope === scope && row.percent !== null && row.percent >= EXHAUSTED_PERCENT + )) + const shared = hit('all-models') + if (shared) return { provider: snapshot.provider, exhausted: true, scope: 'all-models', resetsAt: shared.resetsAt, label: shared.label } + const family = hit('model-family') + if (family) return { provider: snapshot.provider, exhausted: true, scope: 'model-family', resetsAt: family.resetsAt, label: family.label } + return { provider: snapshot.provider, exhausted: false, scope: 'unknown', resetsAt: null, label: '' } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `NODE_ENV=test npx vitest run --project unit src/main/usage src/shared/usage` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/types/usage.ts src/shared/usage src/main/usage +git commit -m "feat(usage): scope limit rows and derive a structural exhaustion signal + +Refs #821" +``` + +--- + +### Task 8: Renderer runtime signal, guard, batch policy, and modal (Stage 6) + +**Files:** +- Modify: `src/renderer/src/session-runtime/state.ts:552` (`limitHit`), `:823` (`emptyRuntime`) +- Modify: `src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts:1455` (JSONL handler) and `:1136` (semantic api_error) +- Modify: `src/renderer/src/workspace/hook/actions/providerSwitchCore.ts` (guard, `contextPolicy`, strategy in result) +- Modify: `src/renderer/src/workspace/hook/actions/bulkProviderSwitch.ts` (policy parameter, strategy tally) +- Modify: `src/renderer/src/workspace/hook/index.ts:835,983` (pass-through only) +- Modify: `src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx` +- Create: `src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.policy.renderer.test.tsx` +- Create: `src/renderer/src/workspace/hook/actions/providerSwitchCore.limitIdle.test.ts` + +**Interfaces:** +- Consumes: `deriveProviderExhaustion`, `useUsageHeaderSnapshot`, `SwitchContextPolicy` (via preload types). +- Produces: `SessionRuntime.limitHit: { at: number; source: 'transcript' | 'api_error' } | null`; `switchAgentsToProvider(sessionIds, targetKind, policy: { allowSourceTurns: boolean; compactOnArrival: boolean; sourceCompactionConfirmed: boolean })`; `SwitchAgentProviderResult` `switched` gains `strategy: 'native' | 'raw' | 'shrunk'`; `isLimitIdle(runtime): boolean` exported from `providerSwitchCore.ts`. + +- [ ] **Step 1: Write the failing guard test** + +```ts +// providerSwitchCore.limitIdle.test.ts +import { describe, expect, it } from 'vitest' +import { emptyRuntime } from '@renderer/session-runtime/state' +import { isLimitIdle } from './providerSwitchCore' +describe('isLimitIdle', () => { + it('treats a process still marked active as idle when a limit hit is newer than the last turn start', () => { + const runtime = { ...emptyRuntime(), processActive: true, turnStartedAt: 1_000, limitHit: { at: 2_000, source: 'transcript' as const } } + expect(isLimitIdle(runtime)).toBe(true) + }) + it('does not override a genuinely running turn', () => { + const runtime = { ...emptyRuntime(), processActive: true, turnStartedAt: 3_000, limitHit: { at: 2_000, source: 'transcript' as const } } + expect(isLimitIdle(runtime)).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `NODE_ENV=test npx vitest run --project unit src/renderer/src/workspace/hook/actions/providerSwitchCore.limitIdle.test.ts` +Expected: FAIL — `isLimitIdle` and `limitHit` do not exist. + +- [ ] **Step 3: Implement the runtime field and guard** + +`state.ts`: add after `processActive`: + +```ts + /** Set when the provider reported a usage limit (Claude: an api_error + * transcript record; Codex: a usage_limit_reached api error on the + * semantic stream). Compared against turnStartedAt so a pane that is + * "active" only because it shows the provider's wait banner can still be + * switched away. Cleared when the next turn completes. */ + limitHit: { at: number; source: 'transcript' | 'api_error' } | null +``` + +and `limitHit: null` in `emptyRuntime()`. + +`useIpcSubscriptions.ts`, JSONL handler: before mapping, compute + +```ts + const limitRecord = entries.find(raw => isRecord(raw) && raw.type === 'assistant' && raw.isApiErrorMessage === true && raw.error === 'rate_limit') +``` + +and when found, set `limitHit: { at: Date.now(), source: 'transcript' }` on the runtime in the same reducer that folds the entries. Semantic handler (`api_error` case): when `ev.errorType === 'usage_limit_reached'`, set `limitHit: { at: now, source: 'api_error' }`. In the `turn_completed` fold, set `limitHit: null`. + +`providerSwitchCore.ts`: + +```ts +export function isLimitIdle(runtime: Pick): boolean { + return runtime.limitHit !== null && (runtime.turnStartedAt === null || runtime.limitHit.at >= runtime.turnStartedAt) +} +``` + +and change the guard to: + +```ts + if ((sourceRuntime?.processActive || sourceRuntime?.semantic.currentTurn) && !(sourceRuntime && isLimitIdle(sourceRuntime))) { + return { status: 'failed', message: 'Wait for the current turn to finish before switching provider' } + } +``` + +Add `contextPolicy` and `sourceCompactionConfirmed` to the `switchAgentProvider` params and forward them in the `window.api.switchProvider` call; include `strategy: result.strategy` in the `switched` result. + +- [ ] **Step 4: Batch policy and tally** + +`bulkProviderSwitch.ts`: `switchAgentsToProvider(sessionIds, targetKind, policy)` forwards `contextPolicy: { allowSourceTurns: policy.allowSourceTurns, compactOnArrival: policy.compactOnArrival }` and `sourceCompactionConfirmed: policy.sourceCompactionConfirmed`; tally `native`, `raw`, `shrunk` counts from `result.strategy`; toast becomes: + +```ts + const tally = [ + counts.native > 0 ? `${counts.native} native` : null, + counts.raw > 0 ? `${counts.raw} raw` : null, + counts.shrunk > 0 ? `${counts.shrunk} shrunk` : null, + ].filter(Boolean).join(', ') + const base = `Switched ${pluralAgents(switched.length)} to ${providerLabel(targetKind)}${tally ? `: ${tally}` : ''}` +``` + +`returnLastProviderSwitchBatch` uses `{ allowSourceTurns: false, compactOnArrival: batch.sourceKind === 'claude', sourceCompactionConfirmed: false }`. + +`hook/index.ts`: no signature change beyond the new parameter flowing through. + +- [ ] **Step 5: Write the failing modal test** + +```tsx +// BulkProviderSwitchModal.policy.renderer.test.tsx +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +vi.mock('@renderer/features/usage/hooks/useUsageHeaderSnapshot', () => ({ + useUsageHeaderSnapshot: () => ({ stale: false, snapshot: { fetchedAt: '2026-09-05T12:00:00Z', cache: { hit: false, ttlMs: 30000 }, providers: [ + { provider: 'codex', status: 'ok', sourceLabel: 's', plan: 'pro', spend: null, extraUsage: null, credits: null, rows: [ + { id: 'codex-primary-window', label: 'Codex 5h', percent: 100, severity: 'critical', resetsAt: '2026-09-05T14:32:00Z', active: true, detail: null, scope: 'all-models' }, + ] }, + { provider: 'claude', status: 'ok', sourceLabel: 's', plan: 'max', spend: null, extraUsage: null, credits: null, rows: [] }, + ] } }), +})) +import { BulkProviderSwitchModal } from './BulkProviderSwitchModal' +import { makeWorkspaceStub } from '../testing/workspaceStub' // the stub the other modal tests use; create it here if none exists + +describe('BulkProviderSwitchModal policy', () => { + it('defaults the direction to the exhausted provider and disables source compaction', () => { + render( {}} />) + expect(screen.getByText(/Codex.*100.*resets/i)).toBeInTheDocument() + expect((screen.getByLabelText(/Compact on source first/i) as HTMLInputElement).disabled).toBe(true) + expect(screen.getByDisplayValue(/Codex → Claude/i)).toBeInTheDocument() + }) +}) +``` + +- [ ] **Step 6: Implement the modal changes** + +In `BulkProviderSwitchModal.tsx`: + +- Read `const { snapshot } = useUsageHeaderSnapshot()` and derive `exhaustion = snapshot?.providers.map(deriveProviderExhaustion) ?? []`. +- On open, if exactly one provider is exhausted, set `directionKey` to `${that}:${firstSwitchTarget}`. +- Render a banner row per exhausted provider: `{providerLabel(p.provider)}: {p.label} at 100%{p.resetsAt ? `, resets ${formatTime(p.resetsAt)}` : ''}` with `formatTime` from `features/usage/model/formatUsage.ts`. +- Two checkboxes with local state: `compactOnArrival` (rendered only when `target === 'claude'`, default `true`), `compactOnSource` (default `false`, `disabled` with title "Source provider is exhausted" when `exhaustion.find(e => e.provider === source)?.exhausted`). +- "Switch model instead" row: rendered only when the source is `claude` and its exhaustion scope is `model-family`; button label `Switch {N} agents to another Claude model` calling `workspace.deliverPromptToSessions(ids, '/model sonnet')` if such an action exists, otherwise looping `window.api.deliverPrompt(sessionId, '/model sonnet')`; on completion toast. This is Claude-only by design (spec §Renderer). +- `runSwitch` passes `{ allowSourceTurns: compactOnSource, compactOnArrival, sourceCompactionConfirmed: compactOnSource }` and, when `compactOnSource` is true, first shows an in-modal confirmation paragraph with a second click required ("Compact N agents on {source} first — this rewrites their live history and uses {source} quota"). +- The ⚠ line changes to: `{midTurnCount} of {n} are mid-turn and will be skipped until idle; agents stopped by a usage limit are included.` where `isLive` for a row is `runtime.processActive && !isLimitIdle(runtime)`. + +- [ ] **Step 7: Run renderer tests, unit tests, and the web typecheck** + +Run: `NODE_ENV=test npx vitest run --project renderer src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.policy.renderer.test.tsx && NODE_ENV=test npx vitest run --project unit src/renderer/src/workspace && npx tsc -p tsconfig.web.json --pretty false && npm run check:keybindings` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add src/renderer/src/session-runtime/state.ts src/renderer/src/workspace/hook src/renderer/src/features/workspace/ui +git commit -m "feat(workspace): read exhaustion in the bulk switch, ask once, and label each agent's strategy + +Refs #821" +``` + +--- + +### Task 9: Integration, design doc, pointers, probe, PR (Stage 7) + +**Files:** +- Modify: `docs/design/provider-switching.md` +- Modify: `package-lock.json` (resync), submodule pointers `packages/agent-transcript-parser`, `packages/codex-headless`, `vendor/codex-src` +- Modify: `packages/agent-transcript-parser/testing/live-resume-probe.mts` (shrunk case) + +- [ ] **Step 1: Bump the package pointers and resync the lockfile (do this BEFORE Task 4 compiles)** + +```bash +git add packages/agent-transcript-parser packages/codex-headless +NODE_ENV=development npm install --include=dev +git add package-lock.json +git commit -m "build(deps): point at the parser and codex-headless branches for quota-independent switching + +Refs #821" +``` + +The parser and codex-headless commits must be pushed to their branches first so CI can fetch them; record the two branch names in the PR body. + +- [ ] **Step 2: Commit the vendored Codex pointer** + +```bash +git add vendor/codex-src +git commit -m "chore(vendor): move vendored Codex source to upstream main + +The provider-switch design cites the current remote compaction, rollout +persistence and external-agent import code; the April checkout predates +all three. + +Refs #821" +``` + +- [ ] **Step 3: Update the design doc** + +In `docs/design/provider-switching.md`: replace the "Capacity outcomes" table with the six-outcome table from the spec, rewrite "Source transcript mutation" to state that source-side compaction is opt-in and disabled while the source is exhausted, add an "Arrival compaction" section, add the `rejected` availability and the API-error decode rule to "Claude compaction", and add the shrink ladder and its isolation to the "Code map" and "Warning" sections. Keep the "Warning" sentence that no fallback silently truncates; the ladder is explicit and reported. + +```bash +git add docs/design/provider-switching.md +git commit -m "docs(provider-switch): describe quota-independent outcomes, opt-in source compaction and arrival compaction + +Refs #821" +``` + +- [ ] **Step 4: Extend the live probe (parser repo) and run it once** + +Add a `--strategy raw|shrunk` flag to `live-resume-probe.mts` that plans with `{ allowSourceTurns: false }` and, for `shrunk`, passes a budget one quarter of the real one so the ladder runs. Run, opt-in, against one real Codex compacted session and one real oversized Claude session: + +```bash +cd packages/agent-transcript-parser +npm run probe:live-resume -- --source codex --target claude --strategy raw --session +npm run probe:live-resume -- --source codex --target claude --strategy shrunk --session +npm run probe:live-resume -- --source claude --target codex --strategy shrunk --session +``` + +Read the three responses for meaning: the marker must be answered and the model must reference the most recent work, not claim an empty history. Record real token counts from the probe against the character estimate (Unknown 2). Attach the report to the PR; do not commit it. + +- [ ] **Step 5: Full gate** + +Run in the worktree: `npm run typecheck && npm test && npm run check:keybindings` +Expected: PASS. Then `npm run test:package` once. + +- [ ] **Step 6: Open the PR** + +```bash +git push -u origin feat/quota-independent-provider-switch +gh pr create --title "feat(provider-switch): switch agents off an exhausted provider without a source-side summarization turn" --body-file +``` + +Body: problem, implemented behavior, design decisions (raw carry-over, deterministic shrink, arrival compaction, opt-in source path), `Fixes #821`, `Fixes #820`, `Refs #360 #720 #756`, the two package PR links, tests and probe report, limitations (Unknowns 1–8 from the decomposition with their resolutions), and the note that the vendored Codex pointer moved. End with the generated-with footer and session link. Stop. Do not merge. + +--- + +## Self-review + +- Spec coverage: planner outcomes (Task 2), shrink ladder (Task 2), hazard fixes (Tasks 1, 5), transaction policy and strategy (Task 4), arrival compaction (Task 6), exhaustion signal incl. codex-headless classification (Tasks 3, 7), modal banner/checkboxes/one confirmation/strategy summary/switch-model row (Task 8), guard (Task 8), design doc and probe (Task 9), coordination boundaries (Global Constraints). Out-of-scope items are not planned. +- Type consistency: `SwitchContextPolicy`, `SwitchStrategy`, `ShrinkReport`, `describeShrink`, `waitForNewCompactionOn`, `TranscriptWatchTarget`, `isLimitIdle`, `deriveProviderExhaustion`, `UsageLimitScope` are defined once and used with the same names in later tasks. +- Ordering: Task 0 needs approval before Task 2; Task 9 Step 1 (pointer bump) must precede Task 4's compile; Task 3 can run in parallel with Tasks 1–2. diff --git a/docs/superpowers/specs/2026-09-05-quota-independent-provider-switch-design.md b/docs/superpowers/specs/2026-09-05-quota-independent-provider-switch-design.md new file mode 100644 index 00000000..8f9bd208 --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-quota-independent-provider-switch-design.md @@ -0,0 +1,264 @@ +# Quota-independent provider switch — Design + +**Date:** 2026-09-05 +**Branch:** `feat/quota-independent-provider-switch` +**Issue:** [#821](https://github.com/Juliusolsson05/agent-code/issues/821) (feature), +[#820](https://github.com/Juliusolsson05/agent-code/issues/820) (hazard), +[agent-transcript-parser#24](https://github.com/Juliusolsson05/agent-transcript-parser/issues/24), +[codex-headless#46](https://github.com/Juliusolsson05/codex-headless/issues/46) +**Status:** Approved direction from the 2026-09-05 discussion; spec awaiting review. +**Decomposition:** `docs/decomposition/quota-independent-provider-switch.md` + +## Problem + +The bulk provider switch exists for the moment a subscription window is +exhausted. Today the transaction still asks the source provider for a live turn +in the common cases: every Codex session that has ever compacted (146 of the +last 300 local rollouts), any Claude session over Codex's budget (frequent with +a 1M Claude model), and any oversized OpenCode session. When the source is at +its limit those turns fail, and the switch waits 300 seconds per agent, shows a +native dialog per agent, and moves nothing. + +Two constraints from the user shape the fix: no speculative pre-summarization +that spends source tokens on a guess, and no custom summarization prompt as the +product path. + +## What the evidence allows + +- Codex keeps all pre-compaction records on disk. The encrypted summary is the + only opaque part, and Codex itself never decrypts it locally; OpenAI's server + does. So the summary is unreadable everywhere, and unnecessary: the history + it summarizes is present in plaintext. +- Claude's compaction summary is a plaintext carrier and already portable. +- Both targets digest overflow natively with their own quota: Codex pre-turn + auto-compaction from the seeded token count (must stay under the window), + Claude pre-turn auto-compaction and the "Resume from summary" dialog. +- Deterministic shrinking without a model is what both vendors ship: Anthropic's + `clear_tool_uses` context editing, and OpenAI's `externalAgentConfig/import`, + which narrates Claude tool calls into notes and truncates results to 4,000 + characters. The OpenAI importer is Claude → Codex only, ignores compaction + boundaries, drops edit diffs and thinking, and has no size handling. It is an + oracle for our projector, not a replacement. +- Exhaustion is observable structurally: the usage endpoints the app already + polls, Codex `token_count.rate_limits` in every rollout, Claude `rate_limit` + transcript records, and the Codex 429 body on the proxy stream. + +## Design principles + +1. The source provider is never required. Every default path is + read-transcript → plan → deterministic shrink if needed → project → write → + replace pane. +2. Overflow is handled deterministically before projection, so a target never + receives input above its window. The target's own native compaction is the + only summarizer, and only after arrival, using the target's quota. +3. Every lossy step is explicit, reported, and visible to the user as a + strategy label. No silent truncation. +4. The existing source-side compaction path is preserved as an opt-in for the + case where the source is alive and the user prefers a native summary over a + larger raw history. + +## Parser (`agent-transcript-parser`) + +### Planner outcomes + +`planConversationContext(conversation, targetProvider, budgetCharacters, options?)` +gains `options.allowSourceTurns` (default `true`, preserving every current +outcome). With `allowSourceTurns: false` the outcomes are: + +| Outcome | Condition | Conversation returned | +|---|---|---| +| `ready` | no compaction, fits | unchanged | +| `existing-compaction` | latest compaction is portable, tail fits | from that boundary | +| `raw-history` | latest compaction is native-only, raw entries minus native-only compaction entries fit | stripped | +| `shrunk` | anything else that can be made to fit | shrunk, with `report` | +| throws `ConversationUnfittableError` | the last complete user turn alone exceeds the budget | n/a | + +`raw-history` and `shrunk` carry `estimatedCharacters`, `budgetCharacters`, and +for `shrunk` a `ShrinkReport`. + +### Shrink ladder (`operations/shrink.ts`) + +Pure, provider-neutral, one consumer (the planner). Steps, applied in order and +only as far as needed: + +1. **Strip native-only compactions.** Remove `compaction` entries whose + availability is `native-only`. Portable and synthetic compaction entries + stay; they are real content. Report: `strippedCompactions`. +2. **Clear tool results, oldest first.** Replace `tool-result.output` with a + bounded placeholder text + `[tool output cleared during provider switch: N characters]`, keeping the + entry, its `callId`, and `isError`. Walk from the oldest entry forward, never + clearing results inside the most recent `keepRecentTurns` user turns + (default 3). Tool-call inputs are preserved in full; edit diffs live there. + Stop as soon as the estimate fits. Report: `clearedResults`, `clearedChars`. +3. **Trim long inputs.** If still over budget, truncate tool-call inputs longer + than `maxInputChars` (default 8,000) with a marker, oldest first, same + recent-turn protection. Report: `trimmedInputs`. +4. **Drop oldest complete turns.** Reuse the `fitConversationToCharacterBudget` + boundary rules, but the synthetic compaction marker now lists the dropped + user prompts (first 200 characters each, up to 40) so the target knows what + was asked earlier. Any plaintext compaction summary among the dropped + entries is prepended, as today. Report: `droppedEntries`, `droppedTurns`, + `promptIndexChars`. +5. If a single final turn still exceeds the budget, throw `ConversationUnfittableError` + with the report so far. Never emit a fragment. + +Thresholds are set from the Stage 0 census, not from these defaults; the +defaults above are placeholders the census replaces in the same PR. + +### Hazard fixes + +- `compactionAvailability` returns `rejected` when the carrier or boundary text + starts with one of Claude's rate-limit prefixes (`You've hit your`, + `You've used`, `You're now using extra usage`, `You're close to`, + `You're out of extra usage`). `conversationAfterLatestPortableCompaction` + treats `rejected` like `incomplete`. +- Claude decode maps assistant records with `isApiErrorMessage: true` to + `opaque` entries with `nativeType: "api_error"` and keeps the raw record. + Projectors already drop opaque entries. + +### Projection + +No projector rule changes. Cleared results are ordinary `tool-result` entries +with short output; the drop marker is a synthetic `compaction` entry, which +Claude projects as boundary + carrier and Codex as a developer handoff. Both +paths exist today. Structural tests prove acceptance; the live probe proves +semantic acceptance. + +## Host (`agent-code` main process) + +### Transaction + +`SwitchProviderRequest.contextPolicy?: { allowSourceTurns: boolean; compactOnArrival: boolean }` +with defaults `{ allowSourceTurns: false, compactOnArrival: }`. + +- `allowSourceTurns: false`: plan with the parser option; never call + `runtime.compactSource`; emit progress `shrinking` with the report summary + when the outcome is `shrunk`; result carries `strategy: 'native' | 'raw' | 'shrunk'` + (`native` covers `ready` and `existing-compaction`) and a one-line + `shrinkSummary` for toasts. +- `allowSourceTurns: true`: the current path, unchanged, except that + `compactBeforeSwitch` fails immediately when the latest compaction's + availability is `rejected` or when an `api_error` opaque entry appears after + the `/compact` baseline line. The per-agent native confirmation stays on this + path only. +- `overflowPolicy` is kept for compatibility: `truncate` maps to the shrink + ladder restricted to step 4; `fail` is unchanged. + +The #720 retention discipline holds: wait loops keep scalars, not documents. + +### Arrival compaction (`compactOnArrival.ts`) + +New IPC `session:compact-after-switch({ sessionId })`, called by the renderer +after `replaceSession` returns, only when the target is Claude and the policy +asked for it: + +1. Wait for the new session's prompt-ready state through the provider's + delivery readiness. +2. If a `claude.resume-prompt` condition is visible, resolve it with the + "Resume from summary" action; Claude then runs its own compaction. +3. Otherwise deliver `/compact` through `deliverPromptToAgent`. +4. Reuse the compaction wait against the target session and kind; emit + `compacting` progress on the new session id. +5. On timeout or provider error, report a non-fatal toast. The pane is already + live with its full history; the user can compact later. + +Codex and OpenCode targets skip this step; Codex auto-compacts at its own +threshold and the projection is written below it. + +### Exhaustion signal + +`src/shared/usage/exhaustion.ts`: pure derivation from `UsageProviderSnapshot` +to `{ exhausted: boolean; scope: 'all-models' | 'model-family' | 'unknown'; resetsAt: string | null; label: string }`. +Claude: `session` and `weekly_all` rows are all-models; `weekly_scoped` rows are +model-family. Codex: the main `rate_limit` windows are all-models; entries +under `additional_rate_limits` are model-family. `exhausted` is `percent >= 100` +on an active row. The usage IPC returns it alongside the snapshot. + +Live signals: the renderer runtime gains `limitHit: { at: number; source: 'transcript' | 'api_error' } | null`, +set when a Claude `api_error` opaque entry with rate-limit text arrives or a +Codex `usage_limit_reached` api error arrives (codex-headless#46 adds the +errorType with `resetsAt`, `limitId`, `limitName`). Cleared on the next +completed turn. + +## Renderer + +### Bulk modal + +- Banner per provider from the exhaustion signal: "Codex: 5-hour window at + 100 percent, resets 14:32" and the direction defaults to that source. +- "Compact on arrival with Claude" checkbox, shown for Claude targets, default + on when the batch's largest source estimate exceeds 150,000 characters. +- "Compact on source first (uses source quota)" checkbox, default off, disabled + with a reason when the source is exhausted. +- "Switch model instead" row when the hit limit is model-family scoped: runs + `/model ` on the selected agents rather than a provider switch. + Claude only; Codex's backend-driven model switch is left to Codex. +- One confirmation per batch. The per-agent native dialog only appears on the + opt-in source path. +- Summary and toast report strategy counts: "Switched 17 agents to Claude: 9 + native, 6 raw, 2 shrunk (1 failed)". Pane toast per agent shows its strategy. + +### Switch core guard + +`switchAgentProvider` currently refuses while `processActive || semantic.currentTurn`. +It additionally allows a session whose `limitHit.at` is newer than its last turn +start. Replacement kills the process, which is what ends a provider's wait +banner. Whether the banner keeps `processActive` true is Unknown 1 in the +decomposition and is settled by a recording before this guard ships. + +## Failure policy + +The switch aborts without replacing the pane when: the source transcript cannot +be decoded; the target model or profile cannot be resolved; the shrink ladder +cannot fit the last complete turn; projection validation or the target write +fails; the source process exits during the transaction. Arrival compaction +failure is not an abort. The opt-in source path keeps its existing failure +list plus the two hazard checks. + +## Testing + +- Parser: unit tests against redacted real fixtures produced in Stage 0; + structural projection tests for cleared outputs and the drop marker; the live + probe extended with shrunk projections and the marker prompt. No invented + transcript literals. +- Host: `switchProvider.test.ts` and `compactBeforeSwitch.test.ts` extended + with decoded real fixtures; `compactOnArrival.test.ts` with a fake session + manager; `exhaustion.test.ts` on the real usage payloads already in the + suite. +- codex-headless: unit test on a recorded or source-derived 429 body. +- Renderer: modal state tests; the auto-wait screen recording for the guard. +- Merge gate: `npm run typecheck`, unit, system, renderer, package suites in + their repos, probe report attached to the PR. + +## Coordination + +A5/A6 own #808–811 and #813–814; #701 owns picker identity; the operator +toolkit (#812) owns `main/control` and `externalControlMcp`. This work stays +in `providerSwitch/`, `ipc/provider.ts`, the bulk modal and its action, the +usage service, and the two packages. `workspace/hook/index.ts` is touched only +to pass parameters. The vendored Codex pointer moves to upstream main in this +branch as a `chore(vendor)` commit because the design cites its current source. + +## Out of scope + +- Speculative pre-summarization of any kind. +- Custom summarization prompts as a product path. The existing portable-handoff + prompt survives only inside the opt-in source path. +- Using the OpenAI importer as the Claude → Codex product path. +- Moving transcript decoding off the main thread (#764). +- Persisting the remembered batch across restarts. + +## Open constraints + +1. The `processActive` behavior under Claude's auto-wait banner decides whether + the guard change is needed at all; it needs a recording, not reasoning. +2. Character-per-token accuracy for shrunk Codex projections is unverified until + the probe runs; if the estimate is off, the reserve fraction, not the ladder, + is the knob. +3. The 1M Claude target may itself require usage credits on this account. If + arrival returns "Usage credits required for 1M context", the exhaustion + signal must cover the target too; the switch itself still succeeds. +4. Stage 0 may find that the #820 hazard never lands on disk. The parser + rejection and the fast-fail stay regardless; the issue is then closed as + defended rather than reproduced. From 7754c04a9dc4bd969de9ace752a781dab71e5653 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 5 Sep 2026 17:41:34 -0700 Subject: [PATCH 02/19] chore(vendor): move vendored Codex source to upstream main The provider-switch design cites the current remote compaction v2, rollout persistence policy, rate-limit snapshot fields and the external-agent session importer; the April checkout predates all of them. vendor/ is a read-only reference namespace and is skipped by the submodule checkout verifier, so this pointer never affects a build. Refs #821 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01B882eBLjpecu4ibaSL7emu --- vendor/codex-src | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/codex-src b/vendor/codex-src index 8035cb03..47ca4619 160000 --- a/vendor/codex-src +++ b/vendor/codex-src @@ -1 +1 @@ -Subproject commit 8035cb03f1a5061d0342cb8fa3a10a18068ca683 +Subproject commit 47ca4619be10c20c1cec6ee9944738c5b961fa1d From 681667416b85c0b75c6bade80cc7c519cb1bd3a6 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 7 Sep 2026 12:33:55 -0700 Subject: [PATCH 03/19] docs(provider-switch): record approval, the #810 landing and lifted ownership boundaries Refs #821 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd --- .../quota-independent-provider-switch.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/decomposition/quota-independent-provider-switch.md b/docs/decomposition/quota-independent-provider-switch.md index f6142485..7f4c8dc2 100644 --- a/docs/decomposition/quota-independent-provider-switch.md +++ b/docs/decomposition/quota-independent-provider-switch.md @@ -1,7 +1,9 @@ # Quota-independent provider switch -Status: Decomposition written 2026-09-05, awaiting user approval before any -implementation stage starts. Feature issue: [#821](https://github.com/Juliusolsson05/agent-code/issues/821). +Status: Decomposition written 2026-09-05, approved 2026-09-07 as part of the +loose-ends takeover (docs/superpowers/plans/2026-09-07-loose-ends-takeover.md); +Stage 0 census is committed before any product code and presented, not gated. +Feature issue: [#821](https://github.com/Juliusolsson05/agent-code/issues/821). Hazard issue: [#820](https://github.com/Juliusolsson05/agent-code/issues/820). Package issues: [agent-transcript-parser#24](https://github.com/Juliusolsson05/agent-transcript-parser/issues/24), [codex-headless#46](https://github.com/Juliusolsson05/codex-headless/issues/46). @@ -277,7 +279,9 @@ characters; Codex `gpt-6-astra` 272k at 95 percent → 581,400 characters is conservative but unverified against `opencode import`. 8. Whether PR #810 (workspace hook isolation) lands first; if so `bulkProviderSwitch.ts` wiring in `hook/index.ts` must be rebased, not merged - blindly. + blindly. Resolved 2026-09-07: #810 merged on 2026-09-06 (408e3e39). Task 8's + hook/index.ts wiring rebases onto the per-session subscription shape; read + hook/index.ts before editing it. ## Fixture plan @@ -299,3 +303,9 @@ operator toolkit (#812, merged) owns `main/control` and `externalControlMcp`. This work does not touch those directories. The only shared file is `src/renderer/src/workspace/hook/index.ts`, where the bulk actions are wired; changes there are limited to passing new parameters through. + +2026-09-07: every coordinating agent (A5/A6/A8, the toolkit lane) is closed and +its work is merged (#808–#814, #812, #818). No file is reserved. The constraint +that survives is not to overlap the other workstreams of the takeover plan +(#822 fix in work-context/, the PaneHeader test hook, PR #823), none of which +touch this feature's file map. From a48a1c0fec52f4439e47ec27d600cdc766609ae5 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Mon, 7 Sep 2026 12:51:55 -0700 Subject: [PATCH 04/19] docs(provider-switch): record transcript census for the shrink ladder Refs #821 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013SULm3ApxebET2a8eLxKHd --- .../evidence/provider-switch/census.md | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 docs/decomposition/evidence/provider-switch/census.md diff --git a/docs/decomposition/evidence/provider-switch/census.md b/docs/decomposition/evidence/provider-switch/census.md new file mode 100644 index 00000000..121c09eb --- /dev/null +++ b/docs/decomposition/evidence/provider-switch/census.md @@ -0,0 +1,267 @@ +# Transcript census for the quota-independent provider switch + +Stage 0 of `docs/decomposition/quota-independent-provider-switch.md`. Measured +2026-09-07 on this machine's local provider stores. Feature issue +[#821](https://github.com/Juliusolsson05/agent-code/issues/821); hazard issue +[#820](https://github.com/Juliusolsson05/agent-code/issues/820); package issue +[agent-transcript-parser#24](https://github.com/Juliusolsson05/agent-transcript-parser/issues/24). + +**Why this document exists.** The shrink ladder in +`docs/superpowers/specs/2026-09-05-quota-independent-provider-switch-design.md` +decides, in order, what to strip from a conversation that does not fit the +target's budget. Every rung of that ladder is a claim about proportion — "tool +results are where the bytes are", "reasoning is not worth stripping", "dropping +turns is a last resort". Those claims are worth nothing if they were guessed in +a planning session. This is the measurement they must come from. + +**Privacy.** Nothing here identifies a file. Files are referred to by size and +by an index within their predicate. No transcript content is quoted except the +Claude rate-limit message, which is a Claude Code product string, not user data. + +## Method + +Every `*.jsonl` under `~/.claude/projects` and `~/.codex/sessions` was streamed +line by line and each record parsed — 3,328 files, 7.15 GB, no sampling — and +the five Task 0 relationship predicates evaluated against each file. For each +predicate the smallest matching candidate was then handed to the committed +extractor (`testing/corpus/extract-observed-sequences.mts`), which applies its +own smallest-candidate rule and the existing redaction before writing a fixture. + +Two different measurements appear below and they must not be confused: + +- **Real** — the fixture's exact record set decoded *before* redaction. These + are the numbers the ladder thresholds come from. +- **Fixture** — the committed, redacted `source.jsonl` decoded the same way. + Entry counts are identical to Real; byte totals are 0.3–2.4 % of Real, + because redaction replaces every private scalar with `"fixture text"`. + +Both were produced with the census script in the Task 0 brief (kept out of the +repository), extended only to apply each case's `keep` predicate and to report +counts, `estimateConversationCharacters`, and the tool-result share. + +## Local corpus scale, 2026-09-07 + +| Store | Files | Bytes | +|---|---:|---:| +| `~/.codex/sessions` | 1,937 | 5,112,541,916 | +| `~/.claude/projects` | 1,391 | 2,036,779,522 | + +## Candidates per predicate + +| Relationship | Predicate | Matching files | +|---|---|---:| +| `codex-sequence-compacted-once` | exactly one `compacted` record | 230 | +| `codex-sequence-compacted-multi` | at least three `compacted` records | 181 | +| `codex-sequence-rate-limit-snapshot` | `token_count` payload with `rate_limits.rate_limit_reached_type` set | **0** | +| `claude-sequence-rate-limit` | assistant record with `isApiErrorMessage: true` and `error: "rate_limit"` | 7 | +| `claude-sequence-oversized` | estimated semantic characters > 581,400 | 90 | + +Context: 523 Codex rollouts carry at least one `compacted` record (27 % of the +store). 113 Claude transcripts carry an `isApiErrorMessage` record at all; the +`error` values observed are `invalid_request` (68 files), `server_error` (25), +`authentication_failed` (14), `unknown` (11), `rate_limit` (7). + +`codex-sequence-rate-limit-snapshot` has **no local candidate** and no fixture +was written. Codex persists `token_count.rate_limits` in every rollout, but +`rate_limit_reached_type` is null in all 1,937 local rollouts — this account has +never had a Codex window actually run out while recording. Stage 4 must capture +one live, as the decomposition already anticipates. + +## Selected fixtures + +Selection is by smallest serialized kept-record bytes, which coincided with +smallest file size for all four cases. + +| Case | Source file bytes | Records kept | Kept bytes (raw) | Committed `source.jsonl` bytes | +|---|---:|---:|---:|---:| +| `codex-sequence-compacted-once` | 469,134 | 52 of 101 | 250,970 | 19,616 | +| `codex-sequence-compacted-multi` | 694,068 | 154 of 495 | 323,602 | 27,334 | +| `claude-sequence-rate-limit` | 439,615 | 71 of 115 | 390,749 | 61,757 | +| `claude-sequence-oversized` | 1,293,112 | 67 of 70 | 1,261,111 | 63,960 | + +All four are far below the 2 MB fixture cap. They are larger than the ~8 KB +sequence fixtures already in the corpus because they keep whole rollouts, but +redaction collapses them by a further 20–95×. + +## Bytes by entry kind + +Sizes follow `estimateEntryCharacters`: messages and tool calls are measured as +serialized JSON, reasoning and compaction as raw text length. + +### `codex-sequence-compacted-once` — 52 records, 52 entries + +| Entry kind | Entries | Real bytes | Share | Fixture bytes | +|---|---:|---:|---:|---:| +| tool-result | 8 | 49,896 | 51.6 % | 360 | +| message/assistant | 6 | 40,588 | 42.0 % | 234 | +| tool-call | 8 | 2,978 | 3.1 % | 112 | +| message/developer | 2 | 2,663 | 2.8 % | 78 | +| message/user | 4 | 619 | 0.6 % | 156 | +| reasoning | 14 | 0 | 0 % | 0 | +| compaction | 1 | 0 | 0 % | 12 | +| opaque | 9 | 0 | 0 % | 0 | +| **total** | **52** | **96,744** | | **952** | + +`estimateConversationCharacters`: 97,288 (Real), 1,552 (Fixture). +Tool-result share of measured bytes: **51.6 %**. + +### `codex-sequence-compacted-multi` — 154 records, 154 entries + +| Entry kind | Entries | Real bytes | Share | Fixture bytes | +|---|---:|---:|---:|---:| +| message/developer | 4 | 66,172 | 36.9 % | 156 | +| tool-result | 22 | 59,981 | 33.5 % | 308 | +| message/assistant | 32 | 31,966 | 17.8 % | 1,248 | +| message/user | 58 | 15,742 | 8.8 % | 2,262 | +| tool-call | 22 | 5,296 | 3.0 % | 308 | +| compaction | 4 | 0 | 0 % | 48 | +| reasoning | 9 | 0 | 0 % | 0 | +| opaque | 3 | 0 | 0 % | 0 | +| **total** | **154** | **179,157** | | **4,330** | + +`estimateConversationCharacters`: 182,484 (Real), 7,636 (Fixture). +Tool-result share of measured bytes: **33.5 %**. + +### `claude-sequence-rate-limit` — 71 records, 71 entries + +| Entry kind | Entries | Real bytes | Share | Fixture bytes | +|---|---:|---:|---:|---:| +| tool-result | 30 | 119,032 | 89.5 % | 420 | +| tool-call | 30 | 9,272 | 7.0 % | 1,680 | +| message/user | 1 | 4,399 | 3.3 % | 39 | +| message/assistant | 2 | 276 | 0.2 % | 78 | +| reasoning | 7 | 0 | 0 % | 84 | +| opaque | 1 | 0 | 0 % | 0 | +| **total** | **71** | **132,979** | | **2,301** | + +`estimateConversationCharacters`: 133,788 (Real), 3,319 (Fixture). +Tool-result share of measured bytes: **89.5 %**. + +The single `opaque` entry is the rate-limit record itself: today's Claude +decoder emits it as an opaque entry, which is what Stage 1 changes to carry +`nativeType: "api_error"`. + +### `claude-sequence-oversized` — 67 records, 67 entries + +| Entry kind | Entries | Real bytes | Share | Fixture bytes | +|---|---:|---:|---:|---:| +| tool-result | 26 | 593,076 | 92.1 % | 364 | +| message/assistant | 5 | 43,496 | 6.8 % | 195 | +| tool-call | 26 | 4,901 | 0.8 % | 1,344 | +| message/user | 1 | 2,623 | 0.4 % | 39 | +| reasoning | 9 | 0 | 0 % | 108 | +| **total** | **67** | **644,096** | | **2,050** | + +`estimateConversationCharacters`: 644,901 (Real), 3,037 (Fixture). +Tool-result share of measured bytes: **92.1 %**. + +## What the measurements say about the ladder + +1. **Clearing tool results is the only rung that matters at these sizes.** Tool + output is 33.5 %, 51.6 %, 89.5 % and 92.1 % of measured bytes in the four + fixtures. Nothing else is close. +2. **The one real over-budget case is fixed by that rung alone.** + `claude-sequence-oversized` estimates 644,901 characters against the + configured Codex budget of 581,400 — 10.9 % over. Clearing tool-result + output removes 593,076 characters and lands at roughly 51,825, about 9 % of + budget. Dropping oldest turns is never reached. The ladder should therefore + be ordered strip-native-only-compactions → clear-tool-results → + drop-oldest-turns, and `dropOldestTurns` should be treated as the rung that + almost never fires rather than the primary mechanism. +3. **Do not build a rung on reasoning.** Reasoning contributes **zero** + measurable characters in all four fixtures. Codex reasoning is encrypted, so + its decoded text is empty by construction. Claude thinking blocks are + persisted with a `signature` and an *empty* `thinking` string: in an 80-file + sample of local Claude transcripts over 200 KB, 5,471 of 5,639 thinking + blocks (97.0 %) have empty thinking text. There is nothing to reclaim. +4. **Codex developer messages are not boilerplate.** They are 36.9 % of the + repeatedly-compacted fixture (4 entries, 66,172 characters — the replacement + history and user instructions that survive a remote compaction). A ladder + that drops or truncates developer messages would delete the only plaintext + left in a compacted Codex thread. +5. **A Codex compaction summary is worth zero characters and carries no + portable text.** All five `compaction` entries across the two Codex fixtures + decode to an empty summary (`summarySource: encrypted`). Budget arithmetic + must not credit a Codex compaction with having "already shrunk" anything. +6. **A single-`compacted` Codex rollout usually has nothing before the + compaction.** In all three smallest candidates the compaction is entry index + 2 of the conversation, with **0** measured bytes before it and 100 % after — + these are resumed or forked threads that *begin* from a compaction, not long + sessions that compacted midway. Only the repeatedly-compacted fixture shows a + real split (94,200 characters before the last compaction, 84,957 after, + 47.4 % after). Consequence for the design: `requires-portable-handoff` is the + ordinary Codex → Claude case, not an edge case, and Stage 2 tests that need + substantial pre-compaction history must use `codex-sequence-compacted-multi`. + See the caveat below on what this means for the `compacted-once` predicate. + +## #820 — does a rate-limit carrier exist on disk? + +Seven local Claude transcripts contain an assistant record with +`isApiErrorMessage: true` and `error: "rate_limit"`. Indexed by ascending file +size; "boundary after error" means a `system` / `compact_boundary` record at a +line index greater than the first rate-limit record. + +| # | File bytes | Records | `rate_limit` records | `compact_boundary` at | Boundary after error? | +|---|---:|---:|---|---|---| +| RL-1 | 439,615 | 115 | 1 (index 114, last record) | none | **no** | +| RL-2 | 1,250,863 | 326 | 1 (index 325, last record) | none | **no** | +| RL-3 | 1,781,679 | 611 | 1 (index 605) | none | **no** | +| RL-4 | 4,101,592 | 1,667 | 1 (index 1,073) | none | **no** | +| RL-5 | 4,311,063 | 1,688 | 1 (index 1,656) | none | **no** | +| RL-6 | 8,988,189 | 4,155 | 63 (indexes 2,727–3,146) | 2,342 (before) | **no** | +| RL-7 | 14,196,311 | 3,750 | 1 (index 2,870) | 2,024 (before), 2,897, 3,668 | **yes** | + +**Answer: the hazard does not manifest on disk in this corpus.** Only RL-7 has a +compaction after a rate-limit error — a manual `/compact` 27 records later — and +its carrier is a genuine 16,632-character summary beginning "This session is +being continued from a previous conversation that ran out of context.", not a +limit message. Its boundary metadata is coherent (`trigger: "manual"`, +`preTokens: 612029`, `postTokens: 9760`). + +Two facts from the same records still argue for the Stage 1 guard: + +- The observed limit message is + `You've hit your monthly spend limit · raise it at claude.ai/settings/usage?from=cc_cli_limit_message · your session limit resets