@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.
- package/CHANGELOG.md +53 -0
- package/dist/cli.js +450 -424
- package/dist/types/cli/usage-cli.d.ts +10 -1
- package/dist/types/commands/usage.d.ts +9 -0
- package/dist/types/config/settings-schema.d.ts +53 -3
- package/dist/types/modes/components/reset-usage-selector.d.ts +12 -0
- package/dist/types/modes/components/session-selector.d.ts +1 -1
- package/dist/types/modes/components/tool-execution.d.ts +14 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +10 -0
- package/dist/types/modes/session-observer-registry.d.ts +2 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/modes/utils/context-usage.d.ts +6 -1
- package/dist/types/session/agent-session.d.ts +14 -1
- package/dist/types/session/auth-storage.d.ts +1 -1
- package/dist/types/session/codex-auto-reset.d.ts +107 -0
- package/dist/types/session/snapcompact-inline.d.ts +105 -4
- package/dist/types/slash-commands/helpers/reset-usage.d.ts +27 -0
- package/dist/types/task/render.d.ts +1 -0
- package/dist/types/tools/todo.d.ts +0 -11
- package/package.json +11 -11
- package/src/cli/usage-cli.ts +187 -16
- package/src/commands/usage.ts +8 -0
- package/src/config/settings-schema.ts +56 -3
- package/src/config/settings.ts +9 -0
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/modes/components/reset-usage-selector.ts +161 -0
- package/src/modes/components/session-selector.ts +8 -2
- package/src/modes/components/settings-selector.ts +62 -47
- package/src/modes/components/tool-execution.ts +18 -0
- package/src/modes/components/transcript-container.ts +23 -1
- package/src/modes/controllers/command-controller.ts +24 -1
- package/src/modes/controllers/selector-controller.ts +68 -0
- package/src/modes/interactive-mode.ts +59 -0
- package/src/modes/session-observer-registry.ts +61 -3
- package/src/modes/theme/theme.ts +2 -2
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/context-usage.ts +75 -1
- package/src/prompts/system/snapcompact-context-frames-note.md +1 -0
- package/src/prompts/system/snapcompact-context-stub.md +1 -0
- package/src/prompts/system/snapcompact-toolresult-note.md +1 -1
- package/src/prompts/tools/browser.md +33 -43
- package/src/prompts/tools/eval.md +27 -50
- package/src/prompts/tools/irc.md +29 -31
- package/src/prompts/tools/read.md +31 -37
- package/src/prompts/tools/todo.md +1 -2
- package/src/sdk.ts +3 -2
- package/src/session/agent-session.ts +131 -6
- package/src/session/auth-storage.ts +3 -0
- package/src/session/codex-auto-reset.ts +190 -0
- package/src/session/snapcompact-inline.ts +396 -59
- package/src/slash-commands/builtin-registry.ts +145 -8
- package/src/slash-commands/helpers/context-report.ts +28 -1
- package/src/slash-commands/helpers/reset-usage.ts +66 -0
- package/src/slash-commands/helpers/usage-report.ts +12 -0
- package/src/task/index.ts +30 -7
- package/src/task/render.ts +34 -19
- 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
|
|
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
|
|
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
|
|
14
|
+
Append `:<sel>` to `path`; bare path = default mode.
|
|
16
15
|
|
|
17
|
-
- _(none)_ — parseable code → structural summary
|
|
18
|
-
- `:50` / `:50-` —
|
|
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
|
|
21
|
-
- `:20+1` — anchor
|
|
22
|
-
- `:5-16,960-973` — multiple ranges in one call (sorted, overlaps merged)
|
|
23
|
-
- `:raw` — verbatim
|
|
24
|
-
- `:2-4:raw`
|
|
25
|
-
- `:conflicts` — one
|
|
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
|
-
-
|
|
28
|
+
- Directory path → depth-limited dirent listing.
|
|
30
29
|
{{#if IS_HL_MODE}}
|
|
31
|
-
-
|
|
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
|
-
-
|
|
33
|
+
- File with explicit selector → lines prefixed with numbers: `41|def alpha():`.
|
|
35
34
|
{{/if}}
|
|
36
35
|
{{/if}}
|
|
37
|
-
- Parseable code without
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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` —
|
|
57
|
+
- `file.db` — tables with row counts
|
|
63
58
|
- `file.db:table` — schema + sample rows
|
|
64
|
-
- `file.db:table:key` —
|
|
65
|
-
- `file.db:table?limit=50&offset=100` —
|
|
66
|
-
- `file.db:table?where=status='active'&order=created:desc` —
|
|
67
|
-
- `file.db?q=SELECT …` — read-only SELECT
|
|
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
|
-
-
|
|
72
|
-
- `:raw`
|
|
73
|
-
- Bare `host:port`
|
|
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
|
-
|
|
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
|
|
81
|
-
-
|
|
82
|
-
-
|
|
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`,
|
|
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
|
-
|
|
2167
|
+
snapcompactSystemPromptMode !== "none" || settings.get("snapcompact.toolResults")
|
|
2167
2168
|
? new SnapcompactInlineTransformer({
|
|
2168
|
-
renderSystemPrompt:
|
|
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) &&
|
|
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
|
*/
|
|
@@ -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
|
+
};
|