@oh-my-pi/pi-coding-agent 15.11.4 → 15.11.6

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 (58) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/cli.js +450 -424
  3. package/dist/types/cli/usage-cli.d.ts +10 -1
  4. package/dist/types/commands/usage.d.ts +9 -0
  5. package/dist/types/config/settings-schema.d.ts +53 -3
  6. package/dist/types/modes/components/reset-usage-selector.d.ts +12 -0
  7. package/dist/types/modes/components/session-selector.d.ts +1 -1
  8. package/dist/types/modes/components/tool-execution.d.ts +14 -0
  9. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  10. package/dist/types/modes/interactive-mode.d.ts +10 -0
  11. package/dist/types/modes/session-observer-registry.d.ts +2 -0
  12. package/dist/types/modes/types.d.ts +2 -0
  13. package/dist/types/modes/utils/context-usage.d.ts +6 -1
  14. package/dist/types/session/agent-session.d.ts +14 -1
  15. package/dist/types/session/auth-storage.d.ts +1 -1
  16. package/dist/types/session/codex-auto-reset.d.ts +107 -0
  17. package/dist/types/session/snapcompact-inline.d.ts +105 -4
  18. package/dist/types/slash-commands/helpers/reset-usage.d.ts +27 -0
  19. package/dist/types/task/render.d.ts +1 -0
  20. package/dist/types/tools/todo.d.ts +0 -11
  21. package/package.json +11 -11
  22. package/src/cli/usage-cli.ts +187 -16
  23. package/src/commands/usage.ts +8 -0
  24. package/src/config/settings-schema.ts +56 -3
  25. package/src/config/settings.ts +9 -0
  26. package/src/internal-urls/docs-index.generated.ts +1 -1
  27. package/src/modes/components/reset-usage-selector.ts +161 -0
  28. package/src/modes/components/session-selector.ts +8 -2
  29. package/src/modes/components/settings-selector.ts +62 -47
  30. package/src/modes/components/tool-execution.ts +18 -0
  31. package/src/modes/components/transcript-container.ts +23 -1
  32. package/src/modes/controllers/command-controller.ts +24 -1
  33. package/src/modes/controllers/selector-controller.ts +68 -0
  34. package/src/modes/interactive-mode.ts +59 -0
  35. package/src/modes/session-observer-registry.ts +61 -3
  36. package/src/modes/theme/theme.ts +2 -2
  37. package/src/modes/types.ts +2 -0
  38. package/src/modes/utils/context-usage.ts +75 -1
  39. package/src/prompts/system/snapcompact-context-frames-note.md +1 -0
  40. package/src/prompts/system/snapcompact-context-stub.md +1 -0
  41. package/src/prompts/system/snapcompact-toolresult-note.md +1 -1
  42. package/src/prompts/tools/browser.md +33 -43
  43. package/src/prompts/tools/eval.md +27 -50
  44. package/src/prompts/tools/irc.md +29 -31
  45. package/src/prompts/tools/read.md +31 -37
  46. package/src/prompts/tools/todo.md +1 -2
  47. package/src/sdk.ts +3 -2
  48. package/src/session/agent-session.ts +131 -6
  49. package/src/session/auth-storage.ts +3 -0
  50. package/src/session/codex-auto-reset.ts +190 -0
  51. package/src/session/snapcompact-inline.ts +396 -59
  52. package/src/slash-commands/builtin-registry.ts +145 -8
  53. package/src/slash-commands/helpers/context-report.ts +28 -1
  54. package/src/slash-commands/helpers/reset-usage.ts +66 -0
  55. package/src/slash-commands/helpers/usage-report.ts +12 -0
  56. package/src/task/index.ts +30 -7
  57. package/src/task/render.ts +34 -19
  58. package/src/tools/todo.ts +8 -128
@@ -1,84 +1,78 @@
1
1
  Read files, directories, archives, SQLite databases, images, documents, internal resources, and web URLs through a single `path` string.
2
2
 
3
3
  <instruction>
4
- - One tool for filesystem, archives, SQLite, images, documents (PDF/DOCX/PPTX/XLSX/RTF/EPUB/ipynb), internal URIs, and web URLs (reader-mode by default).
5
4
  - You SHOULD parallelize independent reads when exploring related files.
6
- - You SHOULD reach for `read` — not a browser/puppeteer tool — for fetching web content.
5
+ - You SHOULD reach for `read` — not a browser/puppeteer tool — for web content; browser only when `read` cannot deliver it.
7
6
  </instruction>
8
7
 
9
8
  ## Parameters
10
9
 
11
- - `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `history://`, `memory://`, `rule://`, `local://`, `vault://`, `mcp://`, `omp://`, `issue://`, `pr://`), or URL. Append `:<sel>` for line ranges, raw mode, or special modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).
10
+ - `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `history://`, `memory://`, `rule://`, `local://`, `vault://`, `mcp://`, `omp://`, `issue://`, `pr://`), or URL. Append `:<sel>` for line ranges or special modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).
12
11
 
13
12
  ## Selectors
14
13
 
15
- Append `:<sel>` to `path`. The bare path falls back to the default mode.
14
+ Append `:<sel>` to `path`; bare path = default mode.
16
15
 
17
- - _(none)_ — parseable code → structural summary (signatures kept, bodies elided); other files → read from the start (up to {{DEFAULT_LIMIT}} lines).
18
- - `:50` / `:50-` — read from line 50 onward.
16
+ - _(none)_ — parseable code → structural summary; other files → from start (up to {{DEFAULT_LIMIT}} lines).
17
+ - `:50` / `:50-` — from line 50 onward.
19
18
  - `:50-200` — lines 50–200 inclusive.
20
- - `:50+150` — 150 lines starting at line 50.
21
- - `:20+1` — anchor on line 20 (single-range reads expand by ≤1 leading and ≤3 trailing context lines).
22
- - `:5-16,960-973` — multiple ranges in one call (sorted, overlaps merged). Multi-range mode returns exact bounds with no context padding.
23
- - `:raw` — verbatim text; no anchors, no summary, no line prefixes.
24
- - `:2-4:raw` or `:raw:2-4` — range AND verbatim; the two compose in either order.
25
- - `:conflicts` — one-line-per-block index of every unresolved git merge conflict.
19
+ - `:50+150` — 150 lines starting at 50.
20
+ - `:20+1` — anchor line 20 (single-range reads pad ≤1 leading / ≤3 trailing context lines).
21
+ - `:5-16,960-973` — multiple ranges in one call (sorted, overlaps merged); exact bounds, no padding.
22
+ - `:raw` — verbatim; no anchors, no summary, no line prefixes.
23
+ - `:2-4:raw` / `:raw:2-4` — range AND verbatim; compose in either order.
24
+ - `:conflicts` — one line per unresolved git merge conflict block.
26
25
 
27
26
  # Files
28
27
 
29
- - Reading a directory path returns a depth-limited dirent listing.
28
+ - Directory path depth-limited dirent listing.
30
29
  {{#if IS_HL_MODE}}
31
- - Reading a file with an explicit selector emits a file snapshot tag header and numbered lines: `[src/foo.ts#1A2B]` then `41:def alpha():`. Copy the `[PATH#TAG]` header for anchored edits; ops use bare line numbers. NEVER fabricate the tag.
30
+ - File with explicit selector snapshot tag header + numbered lines: `[src/foo.ts#1A2B]` then `41:def alpha():`. Copy the `[PATH#TAG]` header for anchored edits; ops use bare line numbers. NEVER fabricate the tag.
32
31
  {{else}}
33
32
  {{#if IS_LINE_NUMBER_MODE}}
34
- - Reading a file with an explicit selector returns lines prefixed with line numbers: `41|def alpha():`.
33
+ - File with explicit selector lines prefixed with numbers: `41|def alpha():`.
35
34
  {{/if}}
36
35
  {{/if}}
37
- - Parseable code without a selector returns a **structural summary**: declarations kept, large bodies collapsed to `..` (merged brace pair) or `…` (standalone). Summarized output ends with a footer demonstrating the multi-range selector you can use to recover the elided bodies, e.g.:
38
-
39
- `[NN lines elided; re-read needed ranges, e.g. <path>:5-16,40-80]`
40
-
41
- Re-issue **only the relevant range(s)** using the multi-range selector (e.g. `<path>:5-16,120-200`). NEVER guess what's inside `..` / `…` — those markers carry no content. NEVER re-read the whole file or use `:raw` when targeted ranges suffice.
36
+ - Parseable code without selector **structural summary**: declarations kept, bodies collapsed to `..` (merged brace pair) or `…` (standalone). The footer shows the recovery selector: `[NN lines elided; re-read needed ranges, e.g. <path>:5-16,40-80]`. Re-issue ONLY the ranges you need via the multi-range selector. `..`/`…` carry no content NEVER guess what's inside; NEVER re-read the whole file or `:raw` when ranges suffice.
42
37
 
43
38
  # Documents & Notebooks
44
39
 
45
- Extracts text from PDF, Word, PowerPoint, Excel, RTF, and EPUB. Notebooks (`.ipynb`) are shown as editable `# %% [type] cell:N` text; edits round-trip back to the underlying JSON preserving notebook metadata. Add `:raw` to a notebook to bypass the converter and read the JSON directly.
40
+ PDF, Word, PowerPoint, Excel, RTF, EPUB → extracted text. Notebooks (`.ipynb`) editable `# %% [type] cell:N` text; edits round-trip to the underlying JSON preserving metadata. `:raw` bypasses the converter.
46
41
 
47
42
  # Images
48
43
 
49
44
  {{#if INSPECT_IMAGE_ENABLED}}
50
- Reading an image path returns metadata (mime, bytes, dimensions, channels, alpha). For actual visual analysis, call `inspect_image` with the path and a question describing what to inspect.
45
+ Image path metadata (mime, bytes, dimensions, channels, alpha). For visual analysis, call `inspect_image` with the path and a question.
51
46
  {{else}}
52
- Reading an image path returns the decoded image inline (PNG, JPEG, GIF, WEBP) for direct visual analysis.
47
+ Image path decoded image inline (PNG, JPEG, GIF, WEBP) for direct visual analysis.
53
48
  {{/if}}
54
49
 
55
50
  # Archives
56
51
 
57
- Supports `.tar`, `.tar.gz`, `.tgz`, `.zip`. Use `archive.ext:path/inside/archive` to read a member, and append a normal selector to the inner path: `archive.zip:dir/file.ts:50-60`.
52
+ `.tar`, `.tar.gz`, `.tgz`, `.zip`. `archive.ext:path/inside/archive` reads a member; inner paths take normal selectors: `archive.zip:dir/file.ts:50-60`.
58
53
 
59
54
  # SQLite
60
55
 
61
56
  For `.sqlite`, `.sqlite3`, `.db`, `.db3`:
62
- - `file.db` — list tables with row counts
57
+ - `file.db` — tables with row counts
63
58
  - `file.db:table` — schema + sample rows
64
- - `file.db:table:key` — single row by primary key
65
- - `file.db:table?limit=50&offset=100` — paginated rows
66
- - `file.db:table?where=status='active'&order=created:desc` — filtered rows
67
- - `file.db?q=SELECT …` — read-only SELECT query
59
+ - `file.db:table:key` — row by primary key
60
+ - `file.db:table?limit=50&offset=100` — pagination
61
+ - `file.db:table?where=status='active'&order=created:desc` — filter/order
62
+ - `file.db?q=SELECT …` — read-only SELECT
68
63
 
69
64
  # URLs
70
65
 
71
- - Default reader-mode: HTML pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs → clean text/markdown.
72
- - `:raw` returns untouched HTML; line selectors (`:50`, `:50-100`, `:50+150`) paginate the cached fetched output.
73
- - Bare `host:port` URLs collide with the selector grammar — add a trailing slash before the selector: `https://example.com/:80`.
66
+ - Reader-mode by default: HTML, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs → clean text/markdown.
67
+ - `:raw` untouched HTML; line selectors (`:50`, `:50-100`, `:50+150`) paginate the cached fetch.
68
+ - Bare `host:port` collides with the selector grammar — add a trailing slash: `https://example.com/:80`.
74
69
 
75
70
  # Internal URIs
76
71
 
77
- `skill://<name>`, `agent://<id>`, `artifact://<id>`, `history://<agentId>`, `memory://root`, `rule://<name>`, `local://<name>.md`, `vault://<vault>/<path>`, `mcp://<uri>`, `omp://<doc>.md`, `issue://<N>`, and `pr://<N>` resolve transparently and accept the same line selectors as filesystem paths. Use `artifact://<id>` to recover full output that a previous bash/eval/tool result spilled or truncated. `history://<agentId>` is an agent's transcript as concise markdown; bare `history://` lists agents.
72
+ All `path` URI schemes resolve transparently and take the same line selectors. `artifact://<id>` recovers full output a previous bash/eval/tool result spilled or truncated. `history://<agentId>` is an agent's transcript as concise markdown; bare `history://` lists agents.
78
73
 
79
74
  <critical>
80
- - You MUST use `read` for every file, directory, archive, and URL inspection. `cat`, `head`, `tail`, `less`, `more`, `ls`, `tar`, `unzip`, `curl`, `wget` are FORBIDDEN — any such bash call is a bug, regardless of how short or convenient it looks.
81
- - You MUST prefer `read` over a browser/puppeteer tool for URL content; only reach for a browser when `read` cannot deliver reasonable content.
82
- - For line ranges, append the selector to `path` (`path="src/foo.ts:50-200"`, `path="src/foo.ts:50+150"`). NEVER substitute `sed -n`, `awk NR`, or `head`/`tail` pipelines.
83
- - Summary footer names ranges to re-read? Re-issue ONLY the ranges you need via the multi-range selector. NEVER guess what's inside `..` / `…` markers — they carry no content.
75
+ - You MUST use `read` for every file, directory, archive, and URL inspection. `cat`, `head`, `tail`, `less`, `more`, `ls`, `tar`, `unzip`, `curl`, `wget` are FORBIDDEN bash calls, however short or convenient.
76
+ - Line ranges go in the selector (`path="src/foo.ts:50-200"`) NEVER `sed -n`, `awk NR`, or `head`/`tail` pipelines.
77
+ - Summary footer names elided ranges? Re-issue ONLY those ranges. NEVER guess `..`/`…` content.
84
78
  </critical>
@@ -2,7 +2,7 @@
2
2
 
3
3
  Manages a phased task list. Pass `ops`: a flat array of operations.
4
4
  The next pending task is auto-promoted to `in_progress` after each completion.
5
- Allowed `op` values are only `init`, `start`, `done`, `drop`, `rm`, `append`, `note`, and `view`. `pending` is a task status, not an `op`; leave not-yet-started tasks implicit in `init`/`append` lists.
5
+ Allowed `op` values are only `init`, `start`, `done`, `drop`, `rm`, `append`, and `view`. `pending` is a task status, not an `op`; leave not-yet-started tasks implicit in `init`/`append` lists.
6
6
 
7
7
  ## Operations
8
8
 
@@ -14,7 +14,6 @@ Allowed `op` values are only `init`, `start`, `done`, `drop`, `rm`, `append`, `n
14
14
  |`drop`|`task` or `phase`|Mark abandoned|
15
15
  |`rm`|`task` or `phase` (optional)|Remove task or phase's tasks; omit both to clear the entire list|
16
16
  |`append`|`phase`, `items: string[]`|Append tasks to `phase`; lazily creates phase|
17
- |`note`|`task`, `text`|Append a note to a task. Reminders for future-you only.|
18
17
  |`view`|—|Read-only: echo the current list without modifying it|
19
18
 
20
19
  ## Anatomy
package/src/sdk.ts CHANGED
@@ -2162,10 +2162,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2162
2162
  // Per-request provider-context transforms. Obfuscate FIRST so secrets are
2163
2163
  // redacted from text before snapcompact rasterizes it into PNG frames.
2164
2164
  // Both operate on the transient outgoing Context only — never persisted.
2165
+ const snapcompactSystemPromptMode = settings.get("snapcompact.systemPrompt");
2165
2166
  const snapcompactInline =
2166
- settings.get("snapcompact.systemPrompt") || settings.get("snapcompact.toolResults")
2167
+ snapcompactSystemPromptMode !== "none" || settings.get("snapcompact.toolResults")
2167
2168
  ? new SnapcompactInlineTransformer({
2168
- renderSystemPrompt: settings.get("snapcompact.systemPrompt"),
2169
+ renderSystemPrompt: snapcompactSystemPromptMode,
2169
2170
  renderToolResults: settings.get("snapcompact.toolResults"),
2170
2171
  })
2171
2172
  : undefined;
@@ -73,6 +73,9 @@ import type {
73
73
  Model,
74
74
  ProviderResponseMetadata,
75
75
  ProviderSessionState,
76
+ ResetCreditAccountStatus,
77
+ ResetCreditRedeemOutcome,
78
+ ResetCreditTarget,
76
79
  ServiceTier,
77
80
  SimpleStreamOptions,
78
81
  TextContent,
@@ -237,6 +240,7 @@ import { normalizeModelContextImages } from "../utils/image-loading";
237
240
  import { buildNamedToolChoice } from "../utils/tool-choice";
238
241
  import type { AuthStorage } from "./auth-storage";
239
242
  import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
243
+ import { defaultCodexAutoRedeemCoordinator, evaluateCodexAutoRedeem } from "./codex-auto-reset";
240
244
  import {
241
245
  type BashExecutionMessage,
242
246
  type CustomMessage,
@@ -5399,11 +5403,7 @@ export class AgentSession {
5399
5403
  #cloneTodoPhases(phases: TodoPhase[]): TodoPhase[] {
5400
5404
  return phases.map(phase => ({
5401
5405
  name: phase.name,
5402
- tasks: phase.tasks.map(task => {
5403
- const out: TodoItem = { content: task.content, status: task.status };
5404
- if (task.notes && task.notes.length > 0) out.notes = [...task.notes];
5405
- return out;
5406
- }),
5406
+ tasks: phase.tasks.map(task => ({ content: task.content, status: task.status })),
5407
5407
  }));
5408
5408
  }
5409
5409
 
@@ -8354,7 +8354,8 @@ export class AgentSession {
8354
8354
 
8355
8355
  return (
8356
8356
  /\bItem with id ['"][^'"]+['"] not found\.?/i.test(errorMessage) ||
8357
- (/previous[ _]?response/i.test(errorMessage) && /not[ _]?found|invalid|expired|stale/i.test(errorMessage))
8357
+ (/previous[ _]?response/i.test(errorMessage) &&
8358
+ /not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i.test(errorMessage))
8358
8359
  );
8359
8360
  }
8360
8361
 
@@ -8720,6 +8721,13 @@ export class AgentSession {
8720
8721
  if (outcome.switched) {
8721
8722
  switchedCredential = true;
8722
8723
  delayMs = 0;
8724
+ } else if (await this.#maybeAutoRedeemCodexReset()) {
8725
+ // A live usage-limit 429 on the active Codex account, with a banked
8726
+ // reset and the opt-in setting on: spend the reset and retry
8727
+ // immediately instead of waiting out the window. Runs after the
8728
+ // free sibling-switch above and before model fallback below.
8729
+ switchedCredential = true;
8730
+ delayMs = 0;
8723
8731
  } else {
8724
8732
  // No sibling credential is usable right now. Wait for whichever
8725
8733
  // comes first: the provider's retry-after window for the current
@@ -10137,6 +10145,123 @@ export class AgentSession {
10137
10145
  });
10138
10146
  }
10139
10147
 
10148
+ /**
10149
+ * Redeem one saved Codex rate-limit reset for a specific account, injecting
10150
+ * the provider base URL like {@link AgentSession.fetchUsageReports}. Powers
10151
+ * the `/usage reset` command and auto-redeem. Never throws for business
10152
+ * outcomes — inspect the returned `code`.
10153
+ */
10154
+ async redeemResetCredit(target: ResetCreditTarget, signal?: AbortSignal): Promise<ResetCreditRedeemOutcome> {
10155
+ return this.#modelRegistry.authStorage.redeemResetCredit({
10156
+ target,
10157
+ baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider),
10158
+ signal,
10159
+ });
10160
+ }
10161
+
10162
+ /**
10163
+ * List saved Codex rate-limit resets per stored account, fetched live from
10164
+ * the dedicated credits endpoint (bypasses the usage cache). Powers the
10165
+ * `/usage reset` account selector.
10166
+ */
10167
+ async listResetCredits(signal?: AbortSignal): Promise<ResetCreditAccountStatus[]> {
10168
+ return this.#modelRegistry.authStorage.listResetCredits({
10169
+ sessionId: this.sessionId,
10170
+ baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider),
10171
+ signal,
10172
+ });
10173
+ }
10174
+
10175
+ /**
10176
+ * Auto-redeem hook for {@link AgentSession.#handleRetryableError}'s
10177
+ * usage-limit branch. Returns `true` only when a saved Codex reset was
10178
+ * actually spent (so the caller retries immediately). Opt-in, reactive, and
10179
+ * heavily gated — see `./codex-auto-reset` and the design in
10180
+ * `local://autoreset-spec.md`. Per-account in-flight dedup lets concurrent
10181
+ * sessions adopt one redeem instead of double-spending.
10182
+ */
10183
+ async #maybeAutoRedeemCodexReset(coordinator = defaultCodexAutoRedeemCoordinator): Promise<boolean> {
10184
+ const cfg = this.settings.getGroup("codexResets");
10185
+ const model = this.model;
10186
+ // Cheap exits before any IO.
10187
+ if (!cfg.autoRedeem || !model || model.provider !== "openai-codex") return false;
10188
+ const authStorage = this.#modelRegistry.authStorage;
10189
+ // Capture identity BEFORE awaits: markUsageLimitReached leaves the
10190
+ // usage-limit session credential sticky, so this names the blocked account.
10191
+ const identity = authStorage.getOAuthAccountIdentity("openai-codex", this.sessionId);
10192
+ const accountKey = (identity?.accountId ?? identity?.email)?.trim().toLowerCase();
10193
+ if (!accountKey) return false;
10194
+ const existing = coordinator.inFlightByAccount.get(accountKey);
10195
+ if (existing) return existing;
10196
+
10197
+ const run = (async (): Promise<boolean> => {
10198
+ const reports = await this.fetchUsageReports();
10199
+ const decision = evaluateCodexAutoRedeem({
10200
+ nowMs: Date.now(),
10201
+ provider: model.provider,
10202
+ modelId: model.id,
10203
+ settings: {
10204
+ autoRedeem: cfg.autoRedeem,
10205
+ minBlockedMinutes: Math.max(0, cfg.minBlockedMinutes),
10206
+ keepCredits: Math.max(0, Math.trunc(cfg.keepCredits)),
10207
+ },
10208
+ identity,
10209
+ reports,
10210
+ attemptedBlockKeys: coordinator.attemptedBlockKeys,
10211
+ lastAttemptAtByAccount: coordinator.lastAttemptAtByAccount,
10212
+ });
10213
+ if (!decision.redeem) {
10214
+ logger.debug("codex-auto-reset: skipped", { reason: decision.reason });
10215
+ return false;
10216
+ }
10217
+ // Commit the attempt BEFORE acting so this block can never re-enter.
10218
+ coordinator.attemptedBlockKeys.add(decision.blockKey);
10219
+ coordinator.lastAttemptAtByAccount.set(decision.accountKey, Date.now());
10220
+ const who = decision.target.email ?? decision.target.accountId ?? "the active account";
10221
+ const outcome = await authStorage.redeemResetCredit({
10222
+ target: decision.target,
10223
+ baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider),
10224
+ // Not tied to the retry abort controller: aborting a consume
10225
+ // mid-flight leaves credit state unknown.
10226
+ signal: AbortSignal.timeout(15_000),
10227
+ });
10228
+ switch (outcome.code) {
10229
+ case "reset": {
10230
+ const left = Math.max(0, decision.availableCount - 1);
10231
+ this.emitNotice(
10232
+ "info",
10233
+ `Auto-redeemed a saved Codex rate-limit reset for ${who} (${left} left); retrying now.`,
10234
+ "codex-auto-reset",
10235
+ );
10236
+ void this.fetchUsageReports();
10237
+ return true;
10238
+ }
10239
+ case "already_redeemed":
10240
+ this.emitNotice(
10241
+ "warning",
10242
+ "A saved Codex reset was already redeemed elsewhere; waiting for the window.",
10243
+ "codex-auto-reset",
10244
+ );
10245
+ return false;
10246
+ case "no_credit":
10247
+ logger.debug("codex-auto-reset: no_credit (snapshot/live mismatch)", { account: accountKey });
10248
+ return false;
10249
+ case "nothing_to_reset":
10250
+ this.emitNotice(
10251
+ "warning",
10252
+ "Codex reset reported nothing to reset; auto-redeem suppressed for this window.",
10253
+ "codex-auto-reset",
10254
+ );
10255
+ return false;
10256
+ default:
10257
+ this.emitNotice("warning", `Codex auto-redeem failed (${outcome.code}).`, "codex-auto-reset");
10258
+ return false;
10259
+ }
10260
+ })().finally(() => coordinator.inFlightByAccount.delete(accountKey));
10261
+ coordinator.inFlightByAccount.set(accountKey, run);
10262
+ return run;
10263
+ }
10264
+
10140
10265
  /**
10141
10266
  * Estimate context tokens from messages, using the last assistant usage when available.
10142
10267
  */
@@ -14,6 +14,9 @@ export type {
14
14
  CredentialOriginKind,
15
15
  OAuthAccountIdentity,
16
16
  OAuthCredential,
17
+ ResetCreditAccountStatus,
18
+ ResetCreditRedeemOutcome,
19
+ ResetCreditTarget,
17
20
  SerializedAuthStorage,
18
21
  SnapshotResponse,
19
22
  StoredAuthCredential,
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Pure decision predicate for auto-redeeming a saved OpenAI Codex rate-limit
3
+ * reset, plus the process-wide coordinator that serializes attempts.
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.
17
+ *
18
+ * THE DECISION-2 TRAP (status MUST NOT be used to find the blocker):
19
+ * `openai-codex.ts` applies the top-level `rate_limit.limit_reached` flag to
20
+ * BOTH the primary (5h) and secondary (weekly) `buildUsageLimit` calls, so when
21
+ * an account is blocked, *both* limit entries carry `status: "exhausted"`
22
+ * 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`.
26
+ *
27
+ * ANTI-WASTE GATES (in evaluation order): the policy must be OFF unless opted
28
+ * in; 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.
39
+ */
40
+ import type { OAuthAccountIdentity, ResetCreditTarget, UsageReport } from "@oh-my-pi/pi-ai";
41
+ import { reportMatchesActiveAccount } from "../slash-commands/helpers/active-oauth-account";
42
+
43
+ /** Weekly window counts as exhausted at `usedFraction >= 0.999` (used_percent >= 99.9). */
44
+ export const WEEKLY_EXHAUSTED_MIN_FRACTION = 0.999;
45
+ /** A weekly reset can never be more than one window length (7d) away; +1h slack for skew. */
46
+ export const MAX_PLAUSIBLE_REMAINING_MS = 7 * 24 * 3_600_000 + 60 * 60_000;
47
+ /** Report must be no older than the 5-min usage cache TTL plus slack. */
48
+ export const REPORT_FRESHNESS_MS = 10 * 60_000;
49
+ /** Per-account cooldown that catches blockKey drift across a minute boundary. */
50
+ export const ATTEMPT_COOLDOWN_MS = 60_000;
51
+ /** Minute bucket for blockKey, absorbing `reset_after_seconds`-derived jitter. */
52
+ export const DEBOUNCE_BUCKET_MS = 60_000;
53
+
54
+ export type CodexAutoRedeemSkipReason =
55
+ | "disabled"
56
+ | "wrong-provider"
57
+ | "spark-model"
58
+ | "no-identity"
59
+ | "no-report"
60
+ | "stale-report"
61
+ | "not-limit-reached"
62
+ | "weekly-not-exhausted"
63
+ | "no-reset-time"
64
+ | "reset-too-soon"
65
+ | "reset-implausible"
66
+ | "credits-unknown"
67
+ | "reserve"
68
+ | "already-attempted"
69
+ | "cooldown";
70
+
71
+ export interface CodexAutoRedeemInput {
72
+ nowMs: number;
73
+ /** `this.model.provider`. */
74
+ provider: string;
75
+ /** `this.model.id`. */
76
+ modelId: string;
77
+ settings: { autoRedeem: boolean; minBlockedMinutes: number; keepCredits: number };
78
+ /** `getOAuthAccountIdentity("openai-codex", sessionId)`, captured at hook entry before any await. */
79
+ identity: OAuthAccountIdentity | undefined;
80
+ /** `session.fetchUsageReports()` (≤5-min cache). */
81
+ reports: UsageReport[] | null;
82
+ attemptedBlockKeys: ReadonlySet<string>;
83
+ lastAttemptAtByAccount: ReadonlyMap<string, number>;
84
+ }
85
+
86
+ export type CodexAutoRedeemDecision =
87
+ | {
88
+ redeem: true;
89
+ target: ResetCreditTarget;
90
+ accountKey: string;
91
+ blockKey: string;
92
+ weeklyResetAtMs: number;
93
+ remainingMs: number;
94
+ availableCount: number;
95
+ }
96
+ | { redeem: false; reason: CodexAutoRedeemSkipReason };
97
+
98
+ /** Trimmed lowercase, or undefined when blank. Mirrors `normalizeIdentityValue` in active-oauth-account.ts. */
99
+ function normalize(value: unknown): string | undefined {
100
+ return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined;
101
+ }
102
+
103
+ /**
104
+ * Decide whether to auto-redeem a saved Codex reset for the active account.
105
+ *
106
+ * Pure: every gate below is a function of the snapshot inputs only. Order
107
+ * matters — cheapest / most-decisive gates first so the common "not eligible"
108
+ * paths short-circuit before any account/report matching.
109
+ */
110
+ export function evaluateCodexAutoRedeem(input: CodexAutoRedeemInput): CodexAutoRedeemDecision {
111
+ const { nowMs, settings } = input;
112
+ if (!settings.autoRedeem) return { redeem: false, reason: "disabled" };
113
+ if (input.provider !== "openai-codex") return { redeem: false, reason: "wrong-provider" };
114
+ // Unknown #1: it is unknown whether a credit resets the separate Spark meter.
115
+ if (input.modelId.includes("-spark")) return { redeem: false, reason: "spark-model" };
116
+
117
+ const accountKey = normalize(input.identity?.accountId) ?? normalize(input.identity?.email);
118
+ if (!accountKey) return { redeem: false, reason: "no-identity" };
119
+
120
+ const report = input.reports?.find(
121
+ r => r.provider === "openai-codex" && reportMatchesActiveAccount(r, input.identity),
122
+ );
123
+ if (!report) return { redeem: false, reason: "no-report" };
124
+ if (nowMs - report.fetchedAt > REPORT_FRESHNESS_MS) return { redeem: false, reason: "stale-report" };
125
+ // The wire's own blocked flag must confirm the 429.
126
+ if (report.metadata?.limitReached !== true) return { redeem: false, reason: "not-limit-reached" };
127
+
128
+ // EXACT ids — never `status` (see the Decision-2 trap in the module docs).
129
+ // The saved reset applies to the WEEKLY window, so that is the blocker we act
130
+ // on. A 5h-only block (weekly still has headroom) self-heals within the hour,
131
+ // so spending a scarce ~monthly credit there would be wasted.
132
+ const weekly = report.limits.find(l => l.id === "openai-codex:secondary");
133
+ const wUsed = weekly?.amount.usedFraction;
134
+ if (!weekly || wUsed === undefined || wUsed < WEEKLY_EXHAUSTED_MIN_FRACTION) {
135
+ return { redeem: false, reason: "weekly-not-exhausted" };
136
+ }
137
+
138
+ const resetsAt = weekly.window?.resetsAt;
139
+ if (resetsAt === undefined) return { redeem: false, reason: "no-reset-time" };
140
+ const remainingMs = resetsAt - nowMs;
141
+ // anti-waste: too close to the natural reset — let it roll over instead of spending a credit.
142
+ if (remainingMs < settings.minBlockedMinutes * 60_000) return { redeem: false, reason: "reset-too-soon" };
143
+ if (remainingMs > MAX_PLAUSIBLE_REMAINING_MS) return { redeem: false, reason: "reset-implausible" };
144
+
145
+ const available = report.resetCredits?.availableCount;
146
+ // can't verify availability from the snapshot → don't spend (precision over recall).
147
+ if (available === undefined) return { redeem: false, reason: "credits-unknown" };
148
+ if (available - Math.max(0, Math.trunc(settings.keepCredits)) < 1) {
149
+ return { redeem: false, reason: "reserve" };
150
+ }
151
+
152
+ const blockKey = `${accountKey}|${Math.round(resetsAt / DEBOUNCE_BUCKET_MS)}`;
153
+ if (input.attemptedBlockKeys.has(blockKey)) return { redeem: false, reason: "already-attempted" };
154
+ const lastAt = input.lastAttemptAtByAccount.get(accountKey);
155
+ if (lastAt !== undefined && nowMs - lastAt < ATTEMPT_COOLDOWN_MS) return { redeem: false, reason: "cooldown" };
156
+
157
+ return {
158
+ redeem: true,
159
+ target: { accountId: input.identity?.accountId, email: input.identity?.email },
160
+ accountKey,
161
+ blockKey,
162
+ weeklyResetAtMs: resetsAt,
163
+ remainingMs,
164
+ availableCount: available,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Process-wide (NOT per-session) coordinator state. Parallel subagent sessions
170
+ * share the same Codex accounts and must not race a double-spend, so this is a
171
+ * single shared container, not a per-session field.
172
+ *
173
+ * - `attemptedBlockKeys`: one attempt EVER per block episode, regardless of
174
+ * outcome — recorded before calling the consume so exceptions can't re-enter.
175
+ * - `lastAttemptAtByAccount`: per-account cooldown timestamps (epoch ms),
176
+ * catching blockKey drift across a minute boundary.
177
+ * - `inFlightByAccount`: serializes per account — a second session for the same
178
+ * account adopts the in-flight promise instead of starting a second consume.
179
+ */
180
+ export interface CodexAutoRedeemCoordinator {
181
+ attemptedBlockKeys: Set<string>;
182
+ lastAttemptAtByAccount: Map<string, number>;
183
+ inFlightByAccount: Map<string, Promise<boolean>>;
184
+ }
185
+
186
+ export const defaultCodexAutoRedeemCoordinator: CodexAutoRedeemCoordinator = {
187
+ attemptedBlockKeys: new Set(),
188
+ lastAttemptAtByAccount: new Map(),
189
+ inFlightByAccount: new Map(),
190
+ };