@oh-my-pi/pi-coding-agent 17.2.1 → 17.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/{CHANGELOG-dj46zzrm.md → CHANGELOG-xfpkakrn.md} +47 -0
  3. package/dist/cli.js +3891 -3885
  4. package/dist/types/config/keybindings.d.ts +6 -1
  5. package/dist/types/config/settings-schema.d.ts +16 -5
  6. package/dist/types/edit/hashline/block-resolver.d.ts +5 -5
  7. package/dist/types/edit/hashline/diff.d.ts +4 -4
  8. package/dist/types/eval/executor-base.d.ts +27 -0
  9. package/dist/types/eval/js/context-manager.d.ts +7 -0
  10. package/dist/types/eval/js/executor.d.ts +2 -0
  11. package/dist/types/extensibility/extensions/runner.d.ts +64 -5
  12. package/dist/types/extensibility/extensions/types.d.ts +15 -0
  13. package/dist/types/extensibility/extensions/wrapper.d.ts +1 -1
  14. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -1
  15. package/dist/types/live/visualizer.d.ts +3 -1
  16. package/dist/types/modes/acp/acp-agent.d.ts +3 -3
  17. package/dist/types/modes/components/keybinding-hints.d.ts +1 -1
  18. package/dist/types/modes/controllers/mcp-command-controller.d.ts +13 -0
  19. package/dist/types/modes/interactive-mode.d.ts +1 -0
  20. package/dist/types/modes/theme/theme.d.ts +1 -1
  21. package/dist/types/modes/types.d.ts +2 -0
  22. package/dist/types/secrets/index.d.ts +22 -0
  23. package/dist/types/secrets/obfuscator.d.ts +1 -1
  24. package/dist/types/session/agent-session-types.d.ts +4 -0
  25. package/dist/types/session/agent-session.d.ts +9 -0
  26. package/dist/types/session/codex-auto-reset.d.ts +182 -64
  27. package/dist/types/session/session-storage.d.ts +5 -2
  28. package/dist/types/session/session-tools.d.ts +2 -1
  29. package/dist/types/session/turn-recovery.d.ts +7 -1
  30. package/dist/types/system-prompt.d.ts +8 -0
  31. package/dist/types/task/executor.d.ts +27 -0
  32. package/dist/types/task/persisted-revive.d.ts +7 -0
  33. package/dist/types/tools/bash-interceptor.d.ts +1 -1
  34. package/dist/types/tools/browser/attach.d.ts +9 -1
  35. package/dist/types/tools/browser/launch.d.ts +20 -1
  36. package/dist/types/tools/browser/registry.d.ts +2 -0
  37. package/dist/types/tools/browser/tab-protocol.d.ts +8 -0
  38. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -0
  39. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  40. package/dist/types/tools/shell-tokenize.d.ts +11 -0
  41. package/dist/types/utils/image-loading.d.ts +2 -0
  42. package/dist/types/web/search/providers/duckduckgo.d.ts +9 -0
  43. package/dist/types/web/search/types.d.ts +2 -2
  44. package/package.json +12 -12
  45. package/src/commit/agentic/index.ts +2 -1
  46. package/src/commit/pipeline.ts +3 -2
  47. package/src/config/keybindings.ts +6 -1
  48. package/src/config/settings-schema.ts +16 -3
  49. package/src/edit/hashline/block-resolver.ts +5 -5
  50. package/src/edit/hashline/diff.ts +22 -15
  51. package/src/edit/hashline/execute.ts +14 -13
  52. package/src/edit/renderer.ts +3 -3
  53. package/src/eval/executor-base.ts +39 -0
  54. package/src/eval/jl/executor.ts +13 -2
  55. package/src/eval/js/context-manager.ts +97 -14
  56. package/src/eval/js/executor.ts +3 -0
  57. package/src/eval/js/index.ts +1 -0
  58. package/src/eval/py/executor.ts +55 -15
  59. package/src/eval/rb/executor.ts +8 -1
  60. package/src/extensibility/extensions/runner.ts +121 -4
  61. package/src/extensibility/extensions/types.ts +15 -0
  62. package/src/extensibility/extensions/wrapper.ts +19 -2
  63. package/src/extensibility/legacy-pi-coding-agent-shim.ts +5 -5
  64. package/src/live/visualizer.ts +8 -1
  65. package/src/lsp/index.ts +51 -11
  66. package/src/main.ts +3 -0
  67. package/src/modes/acp/acp-agent.ts +16 -5
  68. package/src/modes/components/assistant-message.ts +5 -10
  69. package/src/modes/components/custom-editor.ts +1 -1
  70. package/src/modes/components/keybinding-hints.ts +4 -13
  71. package/src/modes/components/status-line/segments.ts +4 -5
  72. package/src/modes/components/tool-execution.ts +4 -5
  73. package/src/modes/controllers/input-controller.ts +18 -40
  74. package/src/modes/controllers/live-command-controller.ts +1 -0
  75. package/src/modes/controllers/mcp-command-controller.ts +28 -11
  76. package/src/modes/interactive-mode.ts +59 -2
  77. package/src/modes/theme/theme.ts +13 -6
  78. package/src/modes/types.ts +2 -0
  79. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  80. package/src/prompts/system/plan-mode-active.md +5 -5
  81. package/src/sdk.ts +47 -16
  82. package/src/secrets/index.ts +83 -3
  83. package/src/secrets/obfuscator.ts +70 -15
  84. package/src/session/agent-session-types.ts +7 -1
  85. package/src/session/agent-session.ts +393 -112
  86. package/src/session/codex-auto-reset.ts +594 -123
  87. package/src/session/eval-runner.ts +2 -0
  88. package/src/session/messages.ts +1 -1
  89. package/src/session/session-maintenance.ts +48 -1
  90. package/src/session/session-manager.ts +4 -3
  91. package/src/session/session-stats.ts +14 -3
  92. package/src/session/session-storage.ts +47 -15
  93. package/src/session/session-tools.ts +30 -2
  94. package/src/session/turn-recovery.ts +15 -2
  95. package/src/slash-commands/builtin-registry.ts +21 -15
  96. package/src/slash-commands/helpers/reset-usage.ts +2 -0
  97. package/src/system-prompt.ts +13 -1
  98. package/src/task/executor.ts +168 -15
  99. package/src/task/persisted-revive.ts +26 -1
  100. package/src/telemetry-export.ts +8 -4
  101. package/src/tools/bash-interceptor.ts +85 -8
  102. package/src/tools/bash.ts +1 -1
  103. package/src/tools/browser/attach.ts +35 -10
  104. package/src/tools/browser/launch.ts +60 -11
  105. package/src/tools/browser/registry.ts +18 -2
  106. package/src/tools/browser/tab-protocol.ts +8 -0
  107. package/src/tools/browser/tab-supervisor.ts +31 -4
  108. package/src/tools/browser/tab-worker.ts +32 -2
  109. package/src/tools/output-meta.ts +16 -1
  110. package/src/tools/shell-tokenize.ts +101 -0
  111. package/src/utils/image-loading.ts +8 -3
  112. package/src/utils/shell-snapshot.ts +31 -21
  113. package/src/web/search/providers/duckduckgo.ts +183 -15
  114. package/src/web/search/types.ts +6 -1
@@ -1,57 +1,87 @@
1
1
  /**
2
- * Pure decision predicate for auto-redeeming a saved OpenAI Codex rate-limit
3
- * reset, plus the process-wide coordinator that serializes attempts.
2
+ * Pool-wide planner for spending saved OpenAI Codex rate-limit resets, plus
3
+ * the process-wide coordinator that serializes attempts.
4
4
  *
5
- * WHY THIS IS REACTIVE-ONLY (never proactive):
6
- * The only trustworthy "blocked right now" signal is a live 429 /
7
- * `usage_limit_reached` from a request authenticated as the session's active
8
- * Codex credential. The session hook calls this predicate from the usage-limit
9
- * branch of the retry pipeline, *after* free remedies (sibling-account switch)
10
- * fail and *before* model fallback. A proactive surface (the status-line usage
11
- * poll) cannot be used: at `used_percent < 100` the account is not actually
12
- * limited, so redeeming would be a credit-wasting no-op; at exactly 100 the
13
- * user may be idle, so the freshly-reset weekly window would tick away with
14
- * nobody working. Saved resets are a scarce, ~monthly, effectively
15
- * irreversible resource every gate here is biased to precision over recall:
16
- * we would rather miss a redeem than waste a credit.
5
+ * A saved reset is a scarce, ~monthly credit — but it is also perishable:
6
+ * every credit carries an `expiresAt`, and an expired credit is worth exactly
7
+ * nothing. The planner therefore balances two failure modes instead of only
8
+ * one: wasting a credit on a window that would have healed anyway, and letting
9
+ * a credit die unspent. Two rules, evaluated over EVERY stored Codex account
10
+ * (not just the session's active one):
11
+ *
12
+ * - `expiring-credit` (salvage; any trigger): the account's soonest available
13
+ * credit expires within `salvageHorizonMs` and the weekly window is at least
14
+ * {@link SALVAGE_MIN_USED_FRACTION} used, so redeeming restores real quota.
15
+ * The `keepCredits` reserve is deliberately ignored here reserving a
16
+ * credit that is about to expire preserves nothing. If the window resets
17
+ * naturally before the credit expires, the next sweep sees a mostly-free
18
+ * window and skips: the credit had nothing left to restore. The backend may
19
+ * refuse a partial-usage consume with `nothing_to_reset`; that outcome is
20
+ * NON-terminal — the episode is deferred (not buried), so the credit is
21
+ * retried as usage grows or a window exhausts before expiry.
22
+ * - `blocked-account` (restore; `blocked` trigger only): a live 429 blocked
23
+ * the turn and no sibling credential could take over, so the pool is dry.
24
+ * Candidates are accounts with at least one genuinely exhausted chat window
25
+ * — 5h primary or weekly secondary. A banked reset clears the account's
26
+ * chat rate limits generally, not just the weekly window: OpenAI's own
27
+ * client consumes one for a 5h-only block while the weekly window still has
28
+ * ~80% headroom (openai/codex#28525). The natural unblock is the LATEST
29
+ * reset among the exhausted windows (the account stays blocked until every
30
+ * one rolls over) and must be far enough away to justify the spend, with
31
+ * credits above the reserve. One candidate is redeemed — active account
32
+ * first, then the account whose credit dies soonest — and the redeem clears
33
+ * its credential blocks so the retry's re-rank picks it up.
34
+ *
35
+ * TRIGGERS: `blocked` runs from the usage-limit branch of the retry pipeline
36
+ * after sibling switch fails, on force-refreshed reports (the cached snapshot
37
+ * predates the 429 that got us there). `sweep` piggybacks on every successful
38
+ * usage-report fetch — the status line polls every 5 minutes while the TUI is
39
+ * open — so expiring credits are caught even when nothing is blocked.
17
40
  *
18
41
  * THE DECISION-2 TRAP (status MUST NOT be used to find the blocker):
19
42
  * `openai-codex.ts` applies the top-level `rate_limit.limit_reached` flag to
20
43
  * BOTH the primary (5h) and secondary (weekly) `buildUsageLimit` calls, so when
21
44
  * an account is blocked, *both* limit entries carry `status: "exhausted"`
22
45
  * regardless of which window is actually at 100%. Only `amount.usedFraction`
23
- * disambiguates which window is the real blocker. This module therefore keys
24
- * eligibility off exact limit ids (`openai-codex:primary` /
25
- * `openai-codex:secondary`) and `usedFraction`, never off `status`.
46
+ * disambiguates. This module keys eligibility off exact limit ids
47
+ * (`openai-codex:primary` / `openai-codex:secondary`) and `usedFraction`,
48
+ * never off `status`, so the plan names the true blocking window(s) and their
49
+ * real unblock time. Whether a short wait (e.g. a 5h-only block) is worth a
50
+ * credit is the `minBlockedMinutes` knob's call, not hardcoded fiat.
26
51
  *
27
- * ANTI-WASTE GATES (in evaluation order): the policy must not be set to "no";
28
- * the active model must be Codex (not Spark — a Spark block lives on a
29
- * separate meter and it is unknown whether a credit even resets it); a fresh
30
- * usage report for the active account must confirm `limitReached`; the WEEKLY
31
- * (secondary) window must be genuinely exhausted — a 5h-only block self-heals
32
- * within the hour, so a credit spent there buys nothing; the natural reset must be far
33
- * enough away to justify spending a ~30-day credit yet within one plausible
34
- * window length; a credit must be verifiably available above the reserve; and
35
- * the same block episode must not have been attempted already (debounce +
36
- * per-account cooldown). All of this is pure — no fetches, no IO. The only
37
- * stateful piece is the {@link CodexAutoRedeemCoordinator} container, whose
38
- * read-only views are passed in so the predicate itself stays deterministic.
52
+ * All of this is pure no fetches, no IO. The only stateful piece is the
53
+ * {@link CodexAutoRedeemCoordinator} container, whose read-only views are
54
+ * passed in so the planner itself stays deterministic.
39
55
  */
40
- import type { OAuthAccountIdentity, ResetCreditTarget, UsageReport } from "@oh-my-pi/pi-ai";
56
+ import type {
57
+ OAuthAccountIdentity,
58
+ ResetCreditAccountStatus,
59
+ ResetCreditTarget,
60
+ UsageReport,
61
+ UsageResetCreditDetail,
62
+ } from "@oh-my-pi/pi-ai";
41
63
  import type { CodexAutoRedeemMode } from "../config/settings-schema";
42
64
  import { reportMatchesActiveAccount } from "../slash-commands/helpers/active-oauth-account";
43
65
 
44
- /** Weekly window counts as exhausted at `usedFraction >= 0.999` (used_percent >= 99.9). */
45
- export const WEEKLY_EXHAUSTED_MIN_FRACTION = 0.999;
66
+ /** A chat window counts as exhausted at `usedFraction >= 0.999` (used_percent >= 99.9). */
67
+ export const WINDOW_EXHAUSTED_MIN_FRACTION = 0.999;
46
68
  /** A weekly reset can never be more than one window length (7d) away; +1h slack for skew. */
47
- export const MAX_PLAUSIBLE_REMAINING_MS = 7 * 24 * 3_600_000 + 60 * 60_000;
69
+ export const MAX_PLAUSIBLE_WEEKLY_REMAINING_MS = 7 * 24 * 3_600_000 + 60 * 60_000;
70
+ /** A 5h reset can never be more than one window length (5h) away; +1h slack for skew. */
71
+ export const MAX_PLAUSIBLE_PRIMARY_REMAINING_MS = 5 * 3_600_000 + 60 * 60_000;
72
+ /** Below this usage on BOTH chat windows a salvaged reset restores too little to bother (and risks a `nothing_to_reset` no-op). */
73
+ export const SALVAGE_MIN_USED_FRACTION = 0.25;
74
+ /** Retry spacing after a non-terminal consume outcome (`nothing_to_reset`, transport failure). */
75
+ export const REDEEM_RETRY_DEFER_MS = 30 * 60_000;
48
76
 
49
77
  /** Report must be no older than the 5-min usage cache TTL plus slack. */
50
78
  export const REPORT_FRESHNESS_MS = 10 * 60_000;
51
- /** Per-account cooldown that catches blockKey drift across a minute boundary. */
79
+ /** Per-account cooldown that catches attempt-key drift across a minute boundary. */
52
80
  export const ATTEMPT_COOLDOWN_MS = 60_000;
53
- /** Minute bucket for blockKey, absorbing `reset_after_seconds`-derived jitter. */
81
+ /** Minute bucket for attempt keys, absorbing `reset_after_seconds`/expiry jitter. */
54
82
  export const DEBOUNCE_BUCKET_MS = 60_000;
83
+ /** Floor between salvage sweeps; dedupe keys make sweeps idempotent, this just avoids useless re-planning. */
84
+ export const SWEEP_MIN_INTERVAL_MS = 60_000;
55
85
 
56
86
  export function shouldEvaluateCodexAutoRedeem(mode: CodexAutoRedeemMode): boolean {
57
87
  return mode !== "no";
@@ -61,120 +91,534 @@ export function shouldPromptCodexAutoRedeem(mode: CodexAutoRedeemMode): boolean
61
91
  return mode === "unset";
62
92
  }
63
93
 
64
- export type CodexAutoRedeemSkipReason =
94
+ /** What woke the planner. `sweep` may only salvage; `blocked` may also restore. */
95
+ export type CodexResetTrigger = "blocked" | "sweep";
96
+
97
+ /** Why one account produced no action (or, with `accountKey: "*"`, a whole rule was off). */
98
+ export type CodexResetSkipReason =
65
99
  | "disabled"
66
100
  | "wrong-provider"
67
101
  | "spark-model"
68
102
  | "no-identity"
69
- | "no-report"
70
103
  | "stale-report"
71
104
  | "not-limit-reached"
72
- | "weekly-not-exhausted"
105
+ | "no-exhausted-window"
106
+ | "deferred"
73
107
  | "no-reset-time"
74
108
  | "reset-too-soon"
75
109
  | "reset-implausible"
76
110
  | "credits-unknown"
111
+ | "no-credits"
77
112
  | "reserve"
113
+ | "no-expiring-credit"
114
+ | "window-mostly-free"
78
115
  | "already-attempted"
79
116
  | "cooldown";
80
117
 
81
- export interface CodexAutoRedeemInput {
118
+ export interface CodexResetPlanInput {
82
119
  nowMs: number;
83
- /** `this.model.provider`. */
120
+ trigger: CodexResetTrigger;
121
+ /** `this.model.provider` — gates the `blocked-account` rule only. */
84
122
  provider: string;
85
- /** `this.model.id`. */
123
+ /** `this.model.id` — gates the `blocked-account` rule only. */
86
124
  modelId: string;
87
- settings: { autoRedeem: boolean; minBlockedMinutes: number; keepCredits: number };
88
- /** `getOAuthAccountIdentity("openai-codex", sessionId)`, captured at hook entry before any await. */
125
+ settings: {
126
+ enabled: boolean;
127
+ /** `blocked-account`: skip when the natural unblock is closer than this. */
128
+ minBlockedMinutes: number;
129
+ /** `blocked-account`: never spend below this many remaining credits. */
130
+ keepCredits: number;
131
+ /** `expiring-credit`: salvage window; `<= 0` disables the rule. */
132
+ salvageHorizonMs: number;
133
+ };
134
+ /** Active account (marks the preferred restore candidate); may be undefined. */
89
135
  identity: OAuthAccountIdentity | undefined;
90
- /** `session.fetchUsageReports()` (≤5-min cache). */
136
+ /** Usage reports for ALL stored accounts (one per account for Codex). */
91
137
  reports: UsageReport[] | null;
92
- attemptedBlockKeys: ReadonlySet<string>;
138
+ attemptedKeys: ReadonlySet<string>;
139
+ /** Episodes parked by a non-terminal consume outcome, keyed by attempt key (epoch ms). */
140
+ deferredUntilByKey: ReadonlyMap<string, number>;
93
141
  lastAttemptAtByAccount: ReadonlyMap<string, number>;
142
+ /**
143
+ * Live 429 evidence for the ACTIVE account: absolute epoch ms when the
144
+ * provider said the account unblocks, derived from the usage-limit error's
145
+ * parsed retry hint AT THE ERROR (absolute, so slow usage IO between the
146
+ * error and planning cannot drift it). Authoritative when the usage report
147
+ * is stale or missing — the report layer can adopt a pre-block in-flight
148
+ * fetch or serve the last-good snapshot when `/wham/usage` fails (it is
149
+ * IP-throttled, so failure right after a 429 is common), and such a
150
+ * snapshot still shows `limitReached: false` with healthy windows. With no
151
+ * usable report at all, a candidate is synthesized from `identity` and the
152
+ * redeem re-checks credits live. Only used on `blocked`.
153
+ */
154
+ activeBlockUnblockAtMs?: number;
94
155
  }
95
156
 
96
- export interface CodexAutoRedeemRedeemDecision {
97
- redeem: true;
157
+ /** One credit to spend. `redeemResetCredit` picks the account's soonest-expiring credit. */
158
+ export interface CodexResetAction {
159
+ reason: "blocked-account" | "expiring-credit";
98
160
  target: ResetCreditTarget;
99
161
  accountKey: string;
100
- blockKey: string;
101
- weeklyResetAtMs: number;
102
- remainingMs: number;
103
- availableCount: number;
162
+ /** Once-per-episode dedupe key; record in `attemptedKeys` BEFORE consuming. */
163
+ attemptKey: string;
164
+ /** Human label for notices/prompts (email preferred). */
165
+ label: string;
166
+ /** Redeemable credits per the report; undefined for a synthesized live-429 candidate. */
167
+ availableCount?: number;
168
+ weeklyUsedFraction?: number;
169
+ /** `blocked-account`: ms until the natural unblock (latest exhausted-window reset). */
170
+ remainingMs?: number;
171
+ /** `blocked-account`: the exhausted chat windows a redeem would clear. */
172
+ blockedWindows?: ("5h" | "weekly")[];
173
+ /** `expiring-credit`: the fuller chat window a redeem restores (for messaging). */
174
+ salvageWindow?: "5h" | "weekly";
175
+ /** `expiring-credit`: used fraction of {@link CodexResetAction.salvageWindow}. */
176
+ salvageUsedFraction?: number;
177
+ /** `expiring-credit`: ms until the credit expires. */
178
+ expiresInMs?: number;
179
+ /** True when this is the session's active account. */
180
+ active: boolean;
181
+ }
182
+
183
+ export interface CodexResetSkip {
184
+ /** Normalized account key, or `"*"` for a rule-wide gate. */
185
+ accountKey: string;
186
+ rule: "blocked-account" | "expiring-credit" | "account";
187
+ reason: CodexResetSkipReason;
104
188
  }
105
189
 
106
- export type CodexAutoRedeemDecision =
107
- | CodexAutoRedeemRedeemDecision
108
- | { redeem: false; reason: CodexAutoRedeemSkipReason };
190
+ export interface CodexResetPlan {
191
+ /** At most one `blocked-account` action (first), then salvages by soonest expiry. */
192
+ actions: CodexResetAction[];
193
+ /** Diagnostics for `logger.debug` and tests. */
194
+ skipped: CodexResetSkip[];
195
+ }
196
+
197
+ /** Soonest future expiry (epoch ms) among available credits, or undefined. */
198
+ function soonestCreditExpiryMs(
199
+ credits: readonly UsageResetCreditDetail[] | undefined,
200
+ nowMs: number,
201
+ ): number | undefined {
202
+ let soonest: number | undefined;
203
+ for (const credit of credits ?? []) {
204
+ if ((credit.status ?? "available") !== "available") continue;
205
+ if (!credit.expiresAt) continue;
206
+ const expiry = Date.parse(credit.expiresAt);
207
+ if (Number.isNaN(expiry) || expiry <= nowMs) continue;
208
+ if (soonest === undefined || expiry < soonest) soonest = expiry;
209
+ }
210
+ return soonest;
211
+ }
109
212
 
110
- /** Trimmed lowercase, or undefined when blank. Mirrors `normalizeIdentityValue` in active-oauth-account.ts. */
111
- function normalize(value: unknown): string | undefined {
112
- return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined;
213
+ interface AccountSnapshot {
214
+ accountKey: string;
215
+ target: ResetCreditTarget;
216
+ label: string;
217
+ active: boolean;
218
+ /** Undefined when synthesized from live 429 evidence without a report. */
219
+ availableCount: number | undefined;
220
+ primaryUsed: number | undefined;
221
+ primaryResetsAt: number | undefined;
222
+ weeklyUsed: number | undefined;
223
+ weeklyResetsAt: number | undefined;
224
+ limitReached: boolean;
225
+ creditExpiresAtMs: number | undefined;
113
226
  }
114
227
 
115
228
  /**
116
- * Decide whether to auto-redeem a saved Codex reset for the active account.
117
- *
118
- * Pure: every gate below is a function of the snapshot inputs only. Order
119
- * matters — cheapest / most-decisive gates first so the common "not eligible"
120
- * paths short-circuit before any account/report matching.
229
+ * Plan which saved Codex resets to spend right now. Pure: a function of the
230
+ * snapshot inputs only. Callers execute the returned actions in order,
231
+ * re-checking `attemptedKeys` immediately before each consume.
121
232
  */
122
- export function evaluateCodexAutoRedeem(input: CodexAutoRedeemInput): CodexAutoRedeemDecision {
233
+ export function planCodexResetRedemptions(input: CodexResetPlanInput): CodexResetPlan {
123
234
  const { nowMs, settings } = input;
124
- if (!settings.autoRedeem) return { redeem: false, reason: "disabled" };
125
- if (input.provider !== "openai-codex") return { redeem: false, reason: "wrong-provider" };
126
- // Unknown #1: it is unknown whether a credit resets the separate Spark meter.
127
- if (input.modelId.includes("-spark")) return { redeem: false, reason: "spark-model" };
128
-
129
- const accountKey = normalize(input.identity?.accountId) ?? normalize(input.identity?.email);
130
- if (!accountKey) return { redeem: false, reason: "no-identity" };
131
-
132
- const report = input.reports?.find(
133
- r => r.provider === "openai-codex" && reportMatchesActiveAccount(r, input.identity),
134
- );
135
- if (!report) return { redeem: false, reason: "no-report" };
136
- if (nowMs - report.fetchedAt > REPORT_FRESHNESS_MS) return { redeem: false, reason: "stale-report" };
137
- // The wire's own blocked flag must confirm the 429.
138
- if (report.metadata?.limitReached !== true) return { redeem: false, reason: "not-limit-reached" };
139
-
140
- // EXACT ids — never `status` (see the Decision-2 trap in the module docs).
141
- // The saved reset applies to the WEEKLY window, so that is the blocker we act
142
- // on. A 5h-only block (weekly still has headroom) self-heals within the hour,
143
- // so spending a scarce ~monthly credit there would be wasted.
144
- const weekly = report.limits.find(l => l.id === "openai-codex:secondary");
145
- const wUsed = weekly?.amount.usedFraction;
146
- if (!weekly || wUsed === undefined || wUsed < WEEKLY_EXHAUSTED_MIN_FRACTION) {
147
- return { redeem: false, reason: "weekly-not-exhausted" };
148
- }
235
+ const skipped: CodexResetSkip[] = [];
236
+ if (!settings.enabled) return { actions: [], skipped: [{ accountKey: "*", rule: "account", reason: "disabled" }] };
149
237
 
150
- const resetsAt = weekly.window?.resetsAt;
151
- if (resetsAt === undefined) return { redeem: false, reason: "no-reset-time" };
152
- const remainingMs = resetsAt - nowMs;
153
- // anti-waste: too close to the natural reset — let it roll over instead of spending a credit.
154
- if (remainingMs < settings.minBlockedMinutes * 60_000) return { redeem: false, reason: "reset-too-soon" };
155
- if (remainingMs > MAX_PLAUSIBLE_REMAINING_MS) return { redeem: false, reason: "reset-implausible" };
156
-
157
- const available = report.resetCredits?.availableCount;
158
- // can't verify availability from the snapshot → don't spend (precision over recall).
159
- if (available === undefined) return { redeem: false, reason: "credits-unknown" };
160
- if (available - Math.max(0, Math.trunc(settings.keepCredits)) < 1) {
161
- return { redeem: false, reason: "reserve" };
238
+ // Rule-wide gates for `blocked-account`: a redeem can only unblock the turn
239
+ // when the turn is actually on Codex, and it is unknown whether a credit
240
+ // resets the separate Spark meter.
241
+ let blockedRuleActive = input.trigger === "blocked";
242
+ if (blockedRuleActive && input.provider !== "openai-codex") {
243
+ blockedRuleActive = false;
244
+ skipped.push({ accountKey: "*", rule: "blocked-account", reason: "wrong-provider" });
162
245
  }
246
+ if (blockedRuleActive && input.modelId.includes("-spark")) {
247
+ blockedRuleActive = false;
248
+ skipped.push({ accountKey: "*", rule: "blocked-account", reason: "spark-model" });
249
+ }
250
+ const salvageRuleActive = settings.salvageHorizonMs > 0;
163
251
 
164
- const blockKey = `${accountKey}|${Math.round(resetsAt / DEBOUNCE_BUCKET_MS)}`;
165
- if (input.attemptedBlockKeys.has(blockKey)) return { redeem: false, reason: "already-attempted" };
166
- const lastAt = input.lastAttemptAtByAccount.get(accountKey);
167
- if (lastAt !== undefined && nowMs - lastAt < ATTEMPT_COOLDOWN_MS) return { redeem: false, reason: "cooldown" };
252
+ const snapshots: AccountSnapshot[] = [];
253
+ // Whether the ACTIVE account produced a usable snapshot, and whether a
254
+ // FRESH report proved it has no credits — both gate the last-resort
255
+ // synthesized candidate below (live 429 with no usable report).
256
+ let activeHasSnapshot = false;
257
+ let activeKnownNoCredits = false;
258
+ for (const report of input.reports ?? []) {
259
+ if (report.provider !== "openai-codex") continue;
260
+ const accountIdValue = report.metadata?.accountId;
261
+ const emailValue = report.metadata?.email;
262
+ const accountId = typeof accountIdValue === "string" && accountIdValue.trim() ? accountIdValue : undefined;
263
+ const email = typeof emailValue === "string" && emailValue.trim() ? emailValue : undefined;
264
+ // Trimmed lowercase, mirroring `normalizeIdentityValue` in active-oauth-account.ts.
265
+ const accountKey = (accountId ?? email)?.trim().toLowerCase();
266
+ if (!accountKey) {
267
+ skipped.push({ accountKey: "*", rule: "account", reason: "no-identity" });
268
+ continue;
269
+ }
270
+ const isActive = reportMatchesActiveAccount(report, input.identity);
271
+ if (nowMs - report.fetchedAt > REPORT_FRESHNESS_MS) {
272
+ skipped.push({ accountKey, rule: "account", reason: "stale-report" });
273
+ continue;
274
+ }
275
+ const available = report.resetCredits?.availableCount;
276
+ // Can't verify availability from the snapshot → don't spend (precision over recall).
277
+ if (available === undefined) {
278
+ skipped.push({ accountKey, rule: "account", reason: "credits-unknown" });
279
+ continue;
280
+ }
281
+ if (available < 1) {
282
+ if (isActive) activeKnownNoCredits = true;
283
+ skipped.push({ accountKey, rule: "account", reason: "no-credits" });
284
+ continue;
285
+ }
286
+ const primary = report.limits.find(l => l.id === "openai-codex:primary");
287
+ const weekly = report.limits.find(l => l.id === "openai-codex:secondary");
288
+ if (isActive) activeHasSnapshot = true;
289
+ snapshots.push({
290
+ accountKey,
291
+ target: { accountId, email },
292
+ label: email ?? accountId ?? accountKey,
293
+ active: isActive,
294
+ availableCount: available,
295
+ primaryUsed: primary?.amount.usedFraction,
296
+ primaryResetsAt: primary?.window?.resetsAt,
297
+ weeklyUsed: weekly?.amount.usedFraction,
298
+ weeklyResetsAt: weekly?.window?.resetsAt,
299
+ limitReached: report.metadata?.limitReached === true,
300
+ creditExpiresAtMs: soonestCreditExpiryMs(report.resetCredits?.credits, nowMs),
301
+ });
302
+ }
168
303
 
169
- return {
170
- redeem: true,
171
- target: { accountId: input.identity?.accountId, email: input.identity?.email },
172
- accountKey,
173
- blockKey,
174
- weeklyResetAtMs: resetsAt,
175
- remainingMs,
176
- availableCount: available,
304
+ const cooledDown = (accountKey: string): boolean => {
305
+ const lastAt = input.lastAttemptAtByAccount.get(accountKey);
306
+ return lastAt !== undefined && nowMs - lastAt < ATTEMPT_COOLDOWN_MS;
177
307
  };
308
+
309
+ // --- blocked-account: pick ONE restore candidate; one open lane is enough,
310
+ // the next block re-plans on live data.
311
+ let restore: CodexResetAction | undefined;
312
+ if (blockedRuleActive) {
313
+ interface RestoreCandidate {
314
+ snapshot: AccountSnapshot;
315
+ remainingMs: number;
316
+ unblockAtMs: number;
317
+ blockedWindows: ("5h" | "weekly")[];
318
+ }
319
+ const candidates: RestoreCandidate[] = [];
320
+ for (const snapshot of snapshots) {
321
+ const rule = "blocked-account" as const;
322
+ const skip = (reason: CodexResetSkipReason) => skipped.push({ accountKey: snapshot.accountKey, rule, reason });
323
+ // Live evidence: the 429 that triggered this pass names the active
324
+ // account directly, outranking a possibly pre-block report snapshot.
325
+ const liveUnblockAtMs = snapshot.active ? input.activeBlockUnblockAtMs : undefined;
326
+ // The wire's own blocked flag must confirm the 429 for THIS account,
327
+ // unless the live block evidence already does.
328
+ if (!snapshot.limitReached && liveUnblockAtMs === undefined) {
329
+ skip("not-limit-reached");
330
+ continue;
331
+ }
332
+ // Identify the exact exhausted window(s) via usedFraction — never
333
+ // `status` (see the Decision-2 trap in the module docs). Either
334
+ // window qualifies: a banked reset also clears a 5h-only block
335
+ // (openai/codex#28525).
336
+ const exhausted: { window: "5h" | "weekly"; resetsAt: number | undefined; plausibleMs: number }[] = [];
337
+ if (snapshot.primaryUsed !== undefined && snapshot.primaryUsed >= WINDOW_EXHAUSTED_MIN_FRACTION) {
338
+ exhausted.push({
339
+ window: "5h",
340
+ resetsAt: snapshot.primaryResetsAt,
341
+ plausibleMs: MAX_PLAUSIBLE_PRIMARY_REMAINING_MS,
342
+ });
343
+ }
344
+ if (snapshot.weeklyUsed !== undefined && snapshot.weeklyUsed >= WINDOW_EXHAUSTED_MIN_FRACTION) {
345
+ exhausted.push({
346
+ window: "weekly",
347
+ resetsAt: snapshot.weeklyResetsAt,
348
+ plausibleMs: MAX_PLAUSIBLE_WEEKLY_REMAINING_MS,
349
+ });
350
+ }
351
+ let unblockAtMs: number;
352
+ let blockedWindows: ("5h" | "weekly")[];
353
+ if (exhausted.length > 0) {
354
+ // Blocked until EVERY exhausted window rolls over — the unblock is
355
+ // the latest reset among them. A missing or implausible reset on any
356
+ // of them means the unblock time is unknown; stay conservative.
357
+ let latest = Number.NEGATIVE_INFINITY;
358
+ let invalid: CodexResetSkipReason | undefined;
359
+ for (const entry of exhausted) {
360
+ if (entry.resetsAt === undefined) {
361
+ invalid = "no-reset-time";
362
+ break;
363
+ }
364
+ if (entry.resetsAt - nowMs > entry.plausibleMs) {
365
+ invalid = "reset-implausible";
366
+ break;
367
+ }
368
+ if (entry.resetsAt > latest) latest = entry.resetsAt;
369
+ }
370
+ if (invalid) {
371
+ skip(invalid);
372
+ continue;
373
+ }
374
+ unblockAtMs = latest;
375
+ blockedWindows = exhausted.map(e => e.window);
376
+ } else if (liveUnblockAtMs !== undefined) {
377
+ // The report's windows look healthy but the live 429 says otherwise:
378
+ // the snapshot predates the block. Trust the provider's own hint for
379
+ // the unblock horizon, classifying the window by its scale.
380
+ if (liveUnblockAtMs - nowMs > MAX_PLAUSIBLE_WEEKLY_REMAINING_MS) {
381
+ skip("reset-implausible");
382
+ continue;
383
+ }
384
+ unblockAtMs = liveUnblockAtMs;
385
+ blockedWindows = [liveUnblockAtMs - nowMs > MAX_PLAUSIBLE_PRIMARY_REMAINING_MS ? "weekly" : "5h"];
386
+ } else {
387
+ skip("no-exhausted-window");
388
+ continue;
389
+ }
390
+ const remainingMs = unblockAtMs - nowMs;
391
+ // Anti-waste: too close to the natural unblock — let it roll over.
392
+ if (remainingMs < settings.minBlockedMinutes * 60_000) {
393
+ skip("reset-too-soon");
394
+ continue;
395
+ }
396
+ if ((snapshot.availableCount ?? 0) - Math.max(0, Math.trunc(settings.keepCredits)) < 1) {
397
+ skip("reserve");
398
+ continue;
399
+ }
400
+ if (input.attemptedKeys.has(blockedAttemptKey(snapshot.accountKey, unblockAtMs))) {
401
+ skip("already-attempted");
402
+ continue;
403
+ }
404
+ const deferredUntil = input.deferredUntilByKey.get(blockedAttemptKey(snapshot.accountKey, unblockAtMs));
405
+ if (deferredUntil !== undefined && nowMs < deferredUntil) {
406
+ skip("deferred");
407
+ continue;
408
+ }
409
+ if (cooledDown(snapshot.accountKey)) {
410
+ skip("cooldown");
411
+ continue;
412
+ }
413
+ candidates.push({ snapshot, remainingMs, unblockAtMs, blockedWindows });
414
+ }
415
+ candidates.sort((a, b) => {
416
+ // Active account first: the sticky session credential stays usable.
417
+ if (a.snapshot.active !== b.snapshot.active) return a.snapshot.active ? -1 : 1;
418
+ // Then spend the credit that dies soonest.
419
+ const aExpiry = a.snapshot.creditExpiresAtMs ?? Number.POSITIVE_INFINITY;
420
+ const bExpiry = b.snapshot.creditExpiresAtMs ?? Number.POSITIVE_INFINITY;
421
+ if (aExpiry !== bExpiry) return aExpiry - bExpiry;
422
+ // Then the deepest bank, then the longest natural wait (most value).
423
+ const aCount = a.snapshot.availableCount ?? 0;
424
+ const bCount = b.snapshot.availableCount ?? 0;
425
+ if (aCount !== bCount) {
426
+ return bCount - aCount;
427
+ }
428
+ return b.remainingMs - a.remainingMs;
429
+ });
430
+ let best = candidates[0];
431
+ // Last resort: the live 429 names the active account but no usable report
432
+ // survived — stale-dropped, fetch failed entirely, or the credits block
433
+ // was absent. Synthesize the candidate from the captured identity; the
434
+ // redeem re-lists credits live, so a blind guess costs nothing
435
+ // (`no_credit` is terminal, `credit_list_failed` defers). A FRESH report
436
+ // proving zero credits suppresses this.
437
+ if (!best && input.activeBlockUnblockAtMs !== undefined && !activeHasSnapshot && !activeKnownNoCredits) {
438
+ const idValue = input.identity?.accountId;
439
+ const emailValue = input.identity?.email;
440
+ const accountId = typeof idValue === "string" && idValue.trim() ? idValue : undefined;
441
+ const email = typeof emailValue === "string" && emailValue.trim() ? emailValue : undefined;
442
+ const accountKey = (accountId ?? email)?.trim().toLowerCase();
443
+ const unblockAtMs = input.activeBlockUnblockAtMs;
444
+ const remainingMs = unblockAtMs - nowMs;
445
+ const skip = (reason: CodexResetSkipReason) =>
446
+ skipped.push({ accountKey: accountKey ?? "*", rule: "blocked-account", reason });
447
+ if (!accountKey) {
448
+ skip("no-identity");
449
+ } else if (Math.max(0, Math.trunc(settings.keepCredits)) > 0) {
450
+ // A reserve cannot be enforced against an unknown balance.
451
+ skip("credits-unknown");
452
+ } else if (remainingMs > MAX_PLAUSIBLE_WEEKLY_REMAINING_MS) {
453
+ skip("reset-implausible");
454
+ } else if (remainingMs < settings.minBlockedMinutes * 60_000) {
455
+ skip("reset-too-soon");
456
+ } else if (input.attemptedKeys.has(blockedAttemptKey(accountKey, unblockAtMs))) {
457
+ skip("already-attempted");
458
+ } else if ((input.deferredUntilByKey.get(blockedAttemptKey(accountKey, unblockAtMs)) ?? 0) > nowMs) {
459
+ skip("deferred");
460
+ } else if (cooledDown(accountKey)) {
461
+ skip("cooldown");
462
+ } else {
463
+ best = {
464
+ snapshot: {
465
+ accountKey,
466
+ target: { accountId, email },
467
+ label: email ?? accountId ?? accountKey,
468
+ active: true,
469
+ availableCount: undefined,
470
+ primaryUsed: undefined,
471
+ primaryResetsAt: undefined,
472
+ weeklyUsed: undefined,
473
+ weeklyResetsAt: undefined,
474
+ limitReached: true,
475
+ creditExpiresAtMs: undefined,
476
+ },
477
+ remainingMs,
478
+ unblockAtMs,
479
+ blockedWindows: [remainingMs > MAX_PLAUSIBLE_PRIMARY_REMAINING_MS ? "weekly" : "5h"],
480
+ };
481
+ }
482
+ }
483
+ if (best) {
484
+ restore = {
485
+ reason: "blocked-account",
486
+ target: best.snapshot.target,
487
+ accountKey: best.snapshot.accountKey,
488
+ attemptKey: blockedAttemptKey(best.snapshot.accountKey, best.unblockAtMs),
489
+ label: best.snapshot.label,
490
+ availableCount: best.snapshot.availableCount,
491
+ weeklyUsedFraction: best.snapshot.weeklyUsed,
492
+ remainingMs: best.remainingMs,
493
+ expiresInMs:
494
+ best.snapshot.creditExpiresAtMs === undefined ? undefined : best.snapshot.creditExpiresAtMs - nowMs,
495
+ blockedWindows: best.blockedWindows,
496
+ active: best.snapshot.active,
497
+ };
498
+ }
499
+ }
500
+
501
+ // --- expiring-credit: salvage every credit that would otherwise die.
502
+ const salvages: CodexResetAction[] = [];
503
+ if (salvageRuleActive) {
504
+ for (const snapshot of snapshots) {
505
+ if (snapshot.accountKey === restore?.accountKey) continue; // restore already spends its soonest credit
506
+ const rule = "expiring-credit" as const;
507
+ const skip = (reason: CodexResetSkipReason) => skipped.push({ accountKey: snapshot.accountKey, rule, reason });
508
+ const expiresAtMs = snapshot.creditExpiresAtMs;
509
+ if (expiresAtMs === undefined || expiresAtMs - nowMs > settings.salvageHorizonMs) {
510
+ skip("no-expiring-credit");
511
+ continue;
512
+ }
513
+ // Value = the fuller of the two chat windows a redeem restores. A
514
+ // 5h-only exhausted account with a light week still gains real quota
515
+ // (openai/codex#28525); a reset on two mostly-free windows restores
516
+ // ~nothing. If usage grows before the credit expires, a later sweep
517
+ // reconsiders.
518
+ const primaryUsed = snapshot.primaryUsed ?? 0;
519
+ const weeklyUsed = snapshot.weeklyUsed ?? 0;
520
+ const salvageUsedFraction = Math.max(primaryUsed, weeklyUsed);
521
+ if (salvageUsedFraction < SALVAGE_MIN_USED_FRACTION) {
522
+ skip("window-mostly-free");
523
+ continue;
524
+ }
525
+ const salvageWindow: "5h" | "weekly" = primaryUsed >= weeklyUsed ? "5h" : "weekly";
526
+ const attemptKey = salvageAttemptKey(snapshot.accountKey, expiresAtMs);
527
+ if (input.attemptedKeys.has(attemptKey)) {
528
+ skip("already-attempted");
529
+ continue;
530
+ }
531
+ const deferredUntil = input.deferredUntilByKey.get(attemptKey);
532
+ if (deferredUntil !== undefined && nowMs < deferredUntil) {
533
+ skip("deferred");
534
+ continue;
535
+ }
536
+ if (cooledDown(snapshot.accountKey)) {
537
+ skip("cooldown");
538
+ continue;
539
+ }
540
+ salvages.push({
541
+ reason: "expiring-credit",
542
+ target: snapshot.target,
543
+ accountKey: snapshot.accountKey,
544
+ attemptKey,
545
+ label: snapshot.label,
546
+ availableCount: snapshot.availableCount,
547
+ weeklyUsedFraction: snapshot.weeklyUsed,
548
+ salvageWindow,
549
+ salvageUsedFraction,
550
+ expiresInMs: expiresAtMs - nowMs,
551
+ active: snapshot.active,
552
+ });
553
+ }
554
+ salvages.sort((a, b) => (a.expiresInMs ?? 0) - (b.expiresInMs ?? 0));
555
+ }
556
+
557
+ const actions = restore ? [restore, ...salvages] : salvages;
558
+ return { actions, skipped };
559
+ }
560
+
561
+ /** One attempt per (account, weekly-reset-minute) block episode. */
562
+ export function blockedAttemptKey(accountKey: string, weeklyResetsAtMs: number): string {
563
+ return `block|${accountKey}|${Math.round(weeklyResetsAtMs / DEBOUNCE_BUCKET_MS)}`;
564
+ }
565
+
566
+ /** One attempt per (account, credit-expiry-minute) salvage episode. */
567
+ export function salvageAttemptKey(accountKey: string, creditExpiresAtMs: number): string {
568
+ return `salvage|${accountKey}|${Math.round(creditExpiresAtMs / DEBOUNCE_BUCKET_MS)}`;
569
+ }
570
+
571
+ /**
572
+ * Overlay LIVE per-account credit state (from the dedicated
573
+ * `rate-limit-reset-credits` route) onto usage reports before a blocked pass.
574
+ *
575
+ * `/wham/usage` credit counts can be stale or pre-feature, and the usage
576
+ * provider only consults the live detail endpoint when the usage payload
577
+ * already reports a POSITIVE count — a stale ZERO is never corrected there.
578
+ * Live data therefore replaces the report's credit block wholesale; accounts
579
+ * with no live row (or a failed lookup) get the block stripped, so the planner
580
+ * treats them as `credits-unknown` instead of trusting a stale count: siblings
581
+ * stay conservative while the active account can still be synthesized from
582
+ * live 429 evidence (the redeem re-lists atomically either way).
583
+ */
584
+ export function overlayLiveResetCredits(
585
+ reports: UsageReport[] | null,
586
+ statuses: readonly ResetCreditAccountStatus[],
587
+ ): UsageReport[] | null {
588
+ if (!reports) return reports;
589
+ return reports.map(report => {
590
+ if (report.provider !== "openai-codex") return report;
591
+ const status = statuses.find(
592
+ s =>
593
+ (!!s.accountId && s.accountId === report.metadata?.accountId) ||
594
+ (!!s.email && s.email === report.metadata?.email),
595
+ );
596
+ if (!status || status.error) return { ...report, resetCredits: undefined };
597
+ return {
598
+ ...report,
599
+ resetCredits: {
600
+ availableCount: status.availableCount,
601
+ credits: status.credits
602
+ .filter(credit => (credit.status ?? "available") === "available")
603
+ .map(credit => ({ grantedAt: credit.grantedAt, expiresAt: credit.expiresAt, status: credit.status })),
604
+ },
605
+ };
606
+ });
607
+ }
608
+
609
+ /**
610
+ * Whether a consume outcome permanently settles its episode. Terminal codes
611
+ * either spent the credit (`reset`), or prove this credit can never be spent
612
+ * (`already_redeemed`, `no_credit` — a live listing that really had nothing).
613
+ * Everything else — `nothing_to_reset` (limits not constrained enough right
614
+ * now), `credit_list_failed` (flaky listing), thrown transport failures,
615
+ * unknown codes — leaves the credit banked: the executor releases the attempt
616
+ * key and
617
+ * defers the episode by {@link REDEEM_RETRY_DEFER_MS} instead of burying a
618
+ * live credit for the rest of the process.
619
+ */
620
+ export function isTerminalRedeemOutcome(code: string): boolean {
621
+ return code === "reset" || code === "already_redeemed" || code === "no_credit";
178
622
  }
179
623
 
180
624
  /**
@@ -182,21 +626,48 @@ export function evaluateCodexAutoRedeem(input: CodexAutoRedeemInput): CodexAutoR
182
626
  * share the same Codex accounts and must not race a double-spend, so this is a
183
627
  * single shared container, not a per-session field.
184
628
  *
185
- * - `attemptedBlockKeys`: one attempt EVER per block episode, regardless of
186
- * outcome — recorded before calling the consume so exceptions can't re-enter.
629
+ * - `attemptedKeys`: one attempt per episode key — recorded before calling the
630
+ * consume so exceptions can't re-enter. Non-terminal outcomes (see
631
+ * {@link isTerminalRedeemOutcome}) release the key again and park the
632
+ * episode in `deferredUntilByKey` instead.
633
+ * - `deferredUntilByKey`: earliest retry time for episodes whose consume was
634
+ * refused non-terminally (`nothing_to_reset`, transport failure).
187
635
  * - `lastAttemptAtByAccount`: per-account cooldown timestamps (epoch ms),
188
- * catching blockKey drift across a minute boundary.
189
- * - `inFlightByAccount`: serializes per account — a second session for the same
190
- * account adopts the in-flight promise instead of starting a second consume.
636
+ * catching attempt-key drift across a minute boundary.
637
+ * - `inFlightByAccount`: serializes blocked passes per account — a second
638
+ * session for the same account adopts the in-flight promise instead of
639
+ * starting a second consume.
640
+ * - `sweepInFlight` / `lastSweepAt` / `sweepPromise`: re-entrancy guard, floor,
641
+ * and settlement handle for the salvage sweep (a redeem refreshes usage,
642
+ * which would recurse into a sweep; the promise lets tests and diagnostics
643
+ * await a fire-and-forget sweep instead of polling).
644
+ * - `notifiedKeys`: headless "run /usage reset" notices already emitted, so a
645
+ * 5-minute sweep cadence can't spam the transcript.
191
646
  */
192
647
  export interface CodexAutoRedeemCoordinator {
193
- attemptedBlockKeys: Set<string>;
648
+ attemptedKeys: Set<string>;
649
+ deferredUntilByKey: Map<string, number>;
194
650
  lastAttemptAtByAccount: Map<string, number>;
195
651
  inFlightByAccount: Map<string, Promise<boolean>>;
652
+ sweepInFlight: boolean;
653
+ lastSweepAt: number;
654
+ /** Settlement of the most recently scheduled sweep (never rejects). */
655
+ sweepPromise: Promise<void> | undefined;
656
+ notifiedKeys: Set<string>;
657
+ }
658
+
659
+ /** Fresh, empty coordinator: backs the process-wide default; inject one per test for isolation. */
660
+ export function createCodexAutoRedeemCoordinator(): CodexAutoRedeemCoordinator {
661
+ return {
662
+ attemptedKeys: new Set(),
663
+ deferredUntilByKey: new Map(),
664
+ lastAttemptAtByAccount: new Map(),
665
+ inFlightByAccount: new Map(),
666
+ sweepInFlight: false,
667
+ lastSweepAt: 0,
668
+ sweepPromise: undefined,
669
+ notifiedKeys: new Set(),
670
+ };
196
671
  }
197
672
 
198
- export const defaultCodexAutoRedeemCoordinator: CodexAutoRedeemCoordinator = {
199
- attemptedBlockKeys: new Set(),
200
- lastAttemptAtByAccount: new Map(),
201
- inFlightByAccount: new Map(),
202
- };
673
+ export const defaultCodexAutoRedeemCoordinator: CodexAutoRedeemCoordinator = createCodexAutoRedeemCoordinator();