@llblab/pi-kit 0.38.1 → 0.38.3

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 (43) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-claude-usage/AGENTS.md +3 -2
  4. package/node_modules/@llblab/pi-claude-usage/CHANGELOG.md +5 -0
  5. package/node_modules/@llblab/pi-claude-usage/README.md +14 -5
  6. package/node_modules/@llblab/pi-claude-usage/lib/model-limit.ts +54 -0
  7. package/node_modules/@llblab/pi-claude-usage/lib/status.ts +17 -5
  8. package/node_modules/@llblab/pi-claude-usage/lib/usage-store.ts +21 -1
  9. package/node_modules/@llblab/pi-claude-usage/package.json +1 -1
  10. package/node_modules/@llblab/pi-state-flow/AGENTS.md +1 -1
  11. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +11 -673
  12. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +18 -0
  13. package/node_modules/@llblab/pi-state-flow/README.md +8 -8
  14. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +3 -6
  15. package/node_modules/@llblab/pi-state-flow/dist/lib/model-interface.js +2 -3
  16. package/node_modules/@llblab/pi-state-flow/dist/lib/semantic-module.d.ts +1 -1
  17. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +2 -6
  18. package/node_modules/@llblab/pi-state-flow/dist/package.json +2 -1
  19. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +5 -5
  20. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +1 -1
  21. package/node_modules/@llblab/pi-state-flow/docs/README.md +7 -7
  22. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +11 -15
  23. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +12 -10
  24. package/node_modules/@llblab/pi-state-flow/docs/fork-contract.md +18 -16
  25. package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +13 -15
  26. package/node_modules/@llblab/pi-state-flow/docs/protocol-composition-decision.md +2 -2
  27. package/node_modules/@llblab/pi-state-flow/docs/public-module-contract.md +16 -6
  28. package/node_modules/@llblab/pi-state-flow/docs/scope-selection-contract.md +25 -23
  29. package/node_modules/@llblab/pi-state-flow/docs/semantic-compatibility-decision.md +14 -12
  30. package/node_modules/@llblab/pi-state-flow/docs/semantic-extensibility-decision.md +2 -2
  31. package/node_modules/@llblab/pi-state-flow/docs/semantic-handoff-contract.md +133 -15
  32. package/node_modules/@llblab/pi-state-flow/docs/semantic-memory-evaluation.md +5 -5
  33. package/node_modules/@llblab/pi-state-flow/docs/semantic-module-contract.md +5 -5
  34. package/node_modules/@llblab/pi-state-flow/docs/single-scope-interface-contract.md +2 -2
  35. package/node_modules/@llblab/pi-state-flow/docs/usage.md +33 -23
  36. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +3 -7
  37. package/node_modules/@llblab/pi-state-flow/lib/model-interface.ts +2 -3
  38. package/node_modules/@llblab/pi-state-flow/lib/semantic-module.ts +1 -1
  39. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +2 -6
  40. package/node_modules/@llblab/pi-state-flow/package.json +2 -1
  41. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +5 -5
  42. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +1 -1
  43. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to `@llblab/pi-kit` are documented here.
4
4
 
5
+ ## 0.38.3: Bundled Package Updates (2026-11-01)
6
+
7
+ - `Exact pins`: `@llblab/pi-state-flow` 0.27.1 → 0.27.4.
8
+ - `Packaging`: Synchronizes the lockfile, bundle aliases and README inventory. Package membership and resource order remain unchanged; packed validation does not certify extension safety or installed-client behavior.
9
+
10
+ ## 0.38.2: Bundled Package Updates (2026-10-30)
11
+
12
+ - `Exact pins`: `@llblab/pi-claude-usage` 0.2.4 → 0.2.5.
13
+ - `Packaging`: Synchronizes the lockfile, bundle aliases and README inventory. Package membership and resource order remain unchanged; packed validation does not certify extension safety or installed-client behavior.
14
+
5
15
  ## 0.38.1: Bundled Package Updates (2026-10-29)
6
16
 
7
17
  - `Exact pins`: `@llblab/pi-claude-usage` 0.2.3 → 0.2.4; `@llblab/pi-clean-room` 0.3.0 → 0.3.1.
package/README.md CHANGED
@@ -13,11 +13,11 @@ Package links lead to the owning repositories for usage, documentation, issues,
13
13
  | Package | Version | Purpose |
14
14
  | --- | ---: | --- |
15
15
  | [`@llblab/pi-actors`](https://github.com/llblab/pi-actors) | `0.55.0` | Inspectable local Runs, reusable Recipes, persistent tools, and delegation Skills |
16
- | [`@llblab/pi-claude-usage`](https://github.com/llblab/pi-claude-usage) | `0.2.4` | Claude subscription quota status and shared per-model Fast toggle for Opus, mirrored in Telegram |
16
+ | [`@llblab/pi-claude-usage`](https://github.com/llblab/pi-claude-usage) | `0.2.5` | Claude subscription quota status and shared per-model Fast toggle for Opus, mirrored in Telegram |
17
17
  | [`@llblab/pi-clean-room`](https://github.com/llblab/pi-clean-room) | `0.3.1` | Isolated nested Pi TUI with named npm extensions and compatible model selection |
18
18
  | [`@llblab/pi-codex-usage`](https://github.com/llblab/pi-codex-usage) | `0.13.2` | OpenAI/legacy Codex subscription quota and Business credit status, with persistent priority Fast toggle mirrored in Telegram |
19
19
  | [`@llblab/pi-grow-loop`](https://github.com/llblab/pi-grow-loop) | `0.9.0` | Visible continuation scheduling and bounded worker Skills through compiled, manifest-owned resources |
20
- | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.27.1` | Scoped context/memory compiler with step-independent current memory, stable Passive/Active modes and visible blocked-state status |
20
+ | [`@llblab/pi-state-flow`](https://github.com/llblab/pi-state-flow) | `0.27.4` | Scoped context/memory compiler with step-independent current memory, stable Passive/Active modes and visible blocked-state status |
21
21
  | [`@llblab/pi-telegram`](https://github.com/llblab/pi-telegram) | `0.58.3` | Telegram companion with per-prompt Steer, queued controls, Reroute/Restore, recoverable connections, previews, follower Threads, files, and voice |
22
22
  | [`@llblab/skills`](https://github.com/llblab/skills) | `1.15.0` | Portable workflows for engineering, review, design, context maintenance, and other focused tasks |
23
23
 
@@ -25,7 +25,7 @@ Versions are exact by design. An upstream release does not change an installed k
25
25
 
26
26
  ## Install
27
27
 
28
- Requires **Pi 1.0.0+** and **Node.js 22.19.0+**. State Flow requires its canonical checkpoint/tail storage format and does not convert unsupported stores in place. Preserve existing stores and consult the [owning package's storage guidance](https://github.com/llblab/pi-state-flow/blob/v0.27.1/docs/usage.md#moving-a-store-and-supported-formats) before changing installations.
28
+ Requires **Pi 1.0.0+** and **Node.js 22.19.0+**. State Flow requires its canonical checkpoint/tail storage format and does not convert unsupported stores in place. Preserve existing stores and consult the [owning package's storage guidance](https://github.com/llblab/pi-state-flow/blob/v0.27.4/docs/usage.md#moving-a-store-and-supported-formats) before changing installations.
29
29
 
30
30
  From NPM:
31
31
 
@@ -5,7 +5,7 @@
5
5
  - `Statusline-first scope`: Own usage state + usage mode; keep quota reporting zero-configuration and optional Fast on the existing terminal status.
6
6
  - Trigger: Considering commands, menus, persisted settings, or notification output.
7
7
  - Action: Prefer deleting the surface unless it is required for the optimistic TUI status widget, optional `pi-telegram` `/start` status-line mirror, or the argument-free shared `/fast`. Register only the `anthropic` provider handler through `@llblab/pi-command-fast` on session_start; release on session_shutdown. Never register the command directly or gate it on quota auth. Claude Fast eligibility is consumer-owned: permit the `claude-opus-` family without a version allowlist; warn `Fast mode is supported only for Opus` for other families without writes. Family eligibility is not proof of backend support; preserve server capability/billing errors. For rejected models, ignore stale unsupported overrides in status/request adaptation, and leave Codex eligibility unchanged. The library owns session WeakMap/reload arbitration and generic JSONC; `lib/fast.ts` owns Claude semantics; require Pi ≥1.0.0 for its assembled-beta payload contract, rather than copying upstream beta defaults. ON is `speed: "fast"` in the current model override; OFF deletes the property. Preserve existing request speed/betas, append the required Fast beta to the native assembled list rather than replacing a header, and keep Fast out of quota state. The optional Telegram row reads the active model's eligible Fast preference at render time and appends plain-text ` fast` (or shows `fast` alone without quota). Toggle redraws through the final terminal boundary without a quota request or success notification; unreadable config fails closed for Fast only.
8
- - `Domain boundaries`: `index.ts` is export-only; `lib/extension.ts` composes lifecycle/Fast registration and request hooks. `lib/status.ts` owns refresh orchestration and terminal timers; `usage-store.ts` owns claims/fencing/mutex, `query.ts` owns OAuth/HTTP, `usage.ts` owns quota normalization, `status-format.ts` owns presentation, and `telegram.ts` owns optional registration. Preserve mature quota/auth/leadership behavior, keep imports acyclic with no domain importing the entrypoint, and keep domain-focused tests in `tests/`. Do not merge usage extensions or redesign polling to add Fast.
8
+ - `Domain boundaries`: `index.ts` is export-only; `lib/extension.ts` composes lifecycle/Fast registration and request hooks. `lib/status.ts` owns refresh orchestration and terminal timers; `usage-store.ts` owns claims/fencing/mutex, `query.ts` owns OAuth/HTTP, `usage.ts` owns quota normalization, `model-limit.ts` owns narrow passive subscription-error recognition and near-empty window inference, `status-format.ts` owns presentation, and `telegram.ts` owns optional registration. Preserve mature quota/auth/leadership behavior, keep imports acyclic with no domain importing the entrypoint, and keep domain-focused tests in `tests/`. Do not merge usage extensions or redesign polling to add Fast.
9
9
  - `Optimistic refresh`: Preserve the last good statusline bar during refresh and transient failures.
10
10
  - Trigger: Updating quota polling or error handling.
11
11
  - Action: Do not collapse the bar while a request is in flight; only show `n/a` or `error` after repeated failures or no usable quota.
@@ -20,4 +20,5 @@
20
20
  - Action: Do not add fallbacks, CLI probes, or API-key paths; API keys have no subscription quota, so report `n/a`. `utilization` is a used percent.
21
21
  - `Rate-limit discipline`: Quota polling must be shared across instances; HTTP 429 triggers shared backoff.
22
22
  - Trigger: Changing refresh cadence, retries, locking, or adding fetch paths.
23
- - Action: Keep the single shared-state protocol in `lib/usage-store.ts`, orchestrated by `lib/status.ts` (leader refreshes every 3 minutes, takeover after 210 seconds by claiming leadership before fetching, a non-waiting OS-backed SQLite mutex around claiming and fenced publication, atomic writes, shared failure backoff). Always re-read the file for request authorization (`owner` + `claimId` + lease) and publication; do not use an in-memory ownership fallback. Lock failures and failed claim writes must deny requests. `mutex.sqlite` stores no quota or leadership data: never unlink/replace it while instances run or evict a paused holder; close or process death releases the mutex. Keep network calls outside critical sections. Instances otherwise only read the file; never add per-instance polling or probing requests.
23
+ - Action: Keep the single shared-state protocol in `lib/usage-store.ts`, orchestrated by `lib/status.ts` (leader refreshes every 2 minutes, takeover after 150 seconds by claiming leadership before fetching, a non-waiting OS-backed SQLite mutex around claiming and fenced publication, atomic writes, shared failure backoff). Always re-read the file for request authorization (`owner` + `claimId` + lease) and publication; do not use an in-memory ownership fallback. Lock failures and failed claim writes must deny requests. `mutex.sqlite` stores no quota or leadership data: never unlink/replace it while instances run or evict a paused holder; close or process death releases the mutex. Keep network calls outside critical sections. Followers otherwise only read the file, except for mutex-protected passive model-limit markers that never renew ownership or change request deadlines; never add per-instance polling or probing requests.
24
+ - `Passive model-limit inference`: Observe live failed assistant messages, not chat text or historical log scans. Only native `anthropic` / `anthropic-messages` Claude models at `https://api.anthropic.com` with Pi OAuth qualify. Require the structured HTTP 429 account-limit error (`type: error`, `error.type: rate_limit_error`, and the exact `This request would exceed your account's rate limit. Please try again later.` message, whitespace normalized); ignore other throttling and billing errors. Infer zero for at most one cached window strictly below 5% remaining. If either measured window is already exhausted, infer nothing else. If both are near-empty, compare primary remaining percent with six times weekly remaining percent, preferring primary on ties; 1:6 is an empirical budget estimate, not an Anthropic guarantee. Preserve measured reports/timestamps and shared request authority; persist only a separate marker under the mutex. Redraw immediately without a quota request or blink, let followers consume the marker on normal ticks, and clear it only on a successful refresh started after the observation. Do not alter Pi's model retries.
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.5: Reactive Quota Indicators
4
+
5
+ - Added an immediate quota-display response to the specific native Anthropic subscription account-limit error. At most one window with strictly less than 5% remaining is inferred exhausted; when both are near-empty, an empirical weekly-to-five-hour budget ratio of 6:1 selects the smaller equivalent balance, preferring five-hour on ties. An already exhausted measured window leaves the other untouched. The shared signal makes no extra quota requests and preserves measured data, leadership and retry deadlines; a successful refresh started after the signal restores authoritative values. Other providers, proxies, API-key models, generic throttling and Fast credit errors do not trigger it.
6
+ - Changed shared quota polling from every three minutes to every two minutes, with follower takeover after two minutes and thirty seconds. Shared usage-endpoint failure backoff is preserved.
7
+
3
8
  ## 0.2.4: Quota Polling Hotfix
4
9
 
5
10
  - Reduced shared quota polling from once a minute to once every three minutes to lower request pressure on Anthropic's usage endpoint. Follower takeover now waits three minutes and thirty seconds so multiple instances preserve the slower cadence. Shared HTTP 429 backoff and local reset countdown updates remain in place.
@@ -18,6 +18,7 @@ This repository is an adaptation of [`pi-codex-usage`](https://github.com/llblab
18
18
 
19
19
  - Shows two counter-moving half-height markers in the statusline bar while quota is loading, then keeps the bar fresh (the countdown ticks locally)
20
20
  - Keeps the last usable bar visible during ordinary refreshes instead of replacing known quota with a loading state
21
+ - Immediately rounds one likely exhausted near-empty window to zero on the specific native Anthropic subscription account-limit error, without making an extra quota request
21
22
  - Dual bar: the top half shows the 5-hour session window and the bottom half shows the 7-day window, 20 steps (5% each) per window
22
23
  - If only one window is returned, shows its remaining percentage and reset countdown instead of a bar
23
24
  - Shown only while the active model uses the Pi `anthropic` provider
@@ -118,23 +119,31 @@ Runtime failure, such as a network or provider error (including rate limiting of
118
119
  claude error
119
120
  ```
120
121
 
122
+ ## Passive Model-Limit Signal
123
+
124
+ For the native `anthropic` provider, Claude model IDs using `anthropic-messages` at `https://api.anthropic.com` with Pi OAuth auth, the extension also observes failed assistant messages. It recognizes HTTP 429 with a JSON error envelope whose `type` is `error`, `error.type` is `rate_limit_error`, and `error.message` is exactly `This request would exceed your account's rate limit. Please try again later.` (ignoring whitespace differences). API-key models, proxies, other providers, generic throttling, Fast credit errors and errors from the usage endpoint do not trigger it.
125
+
126
+ On this signal, at most one cached window with **strictly less than 5% remaining** is immediately displayed as zero. If only one window is near-empty, it is selected; a sole near-empty weekly window also qualifies. If both windows are near-empty, compare the five-hour remaining percentage with **six times the weekly remaining percentage** and select the smaller balance, preferring the five-hour window on ties. This uses an empirical estimate that one weekly budget is approximately six full five-hour budgets, not an official Anthropic quota guarantee. For example, 2% five-hour versus 1% weekly selects five-hour (2 versus 6), while 4% five-hour versus 0.5% weekly selects weekly (4 versus 3). Exactly 5% and larger balances are unchanged. If either measured window is already exhausted, the other window is not additionally zeroed. Existing exhausted-window colors and reset countdowns are reused.
127
+
128
+ This is a display inference, not an authoritative quota measurement: the error itself does not identify the exhausted window. The measured `report` and `updatedAt` remain unchanged. A separate `modelRateLimitedAt` marker is shared under the existing mutex without changing ownership, the lease, or retry deadlines. The receiving instance redraws immediately; other instances pick it up on their normal file-read ticks (within 30 seconds). A successful quota refresh started after the signal clears it; an already-in-flight response or another failure cannot erase it. No extra quota request is made, and Pi's model retry behavior is unchanged.
129
+
121
130
  ## Shared Refresh
122
131
 
123
- The extension ships an export-only [`index.ts`](./index.ts) and a flat domain DAG under `lib/`, matching Codex Usage's layout. [`extension.ts`](./lib/extension.ts) composes lifecycle/Fast wiring; [`status.ts`](./lib/status.ts) orchestrates refresh and redraw; [`usage-store.ts`](./lib/usage-store.ts) owns shared claims/fencing/mutex; [`query.ts`](./lib/query.ts) owns OAuth/HTTP; [`usage.ts`](./lib/usage.ts) normalizes quotas; [`status-format.ts`](./lib/status-format.ts) formats values; [`telegram.ts`](./lib/telegram.ts) owns optional registration; [`fast.ts`](./lib/fast.ts) adapts native Fast requests. Domains never import the entrypoint, and tests are organized by domain in `tests/`.
132
+ The extension ships an export-only [`index.ts`](./index.ts) and a flat domain DAG under `lib/`, matching Codex Usage's layout. [`extension.ts`](./lib/extension.ts) composes lifecycle/Fast wiring; [`status.ts`](./lib/status.ts) orchestrates refresh and redraw; [`usage-store.ts`](./lib/usage-store.ts) owns shared claims/fencing/mutex; [`query.ts`](./lib/query.ts) owns OAuth/HTTP; [`usage.ts`](./lib/usage.ts) normalizes quotas; [`model-limit.ts`](./lib/model-limit.ts) recognizes the narrow passive model-error signal and derives near-empty window overrides; [`status-format.ts`](./lib/status-format.ts) formats values; [`telegram.ts`](./lib/telegram.ts) owns optional registration; [`fast.ts`](./lib/fast.ts) adapts native Fast requests. Domains never import the entrypoint, and tests are organized by domain in `tests/`.
124
133
 
125
134
  To avoid independent polling, instances coordinate through `~/.pi/agent/tmp/pi-claude-usage/usage.json` (percentages and timestamps only, no tokens):
126
135
 
127
- - The instance that last updated the file is the leader and refreshes it every 3 minutes
136
+ - The instance that last updated the file is the leader and refreshes it every 2 minutes
128
137
  - Every other instance only reads the file (re-checking every ≤30s) and redraws when it changes
129
- - Leadership is never cached in memory: before each usage request, the instance re-reads the file and checks its `owner`, unique `claimId`, and 210s lease. Publication rechecks that claim under the lock; a superseded success or failure is discarded. Failed locks or unwritten claims never authorize a request
130
- - If the file is 210 seconds old (leader is closed, busy, or asleep), the first follower that obtains the mutex takes over: it re-reads the JSON, writes itself as the leader with a fresh timestamp, releases the mutex, then fetches from the server and publishes under the mutex after rechecking its claim. Its next refresh is three minutes later; JSON writes remain atomic renames
138
+ - Leadership is never cached in memory: before each usage request, the instance re-reads the file and checks its `owner`, unique `claimId`, and 150s lease. Publication rechecks that claim under the lock; a superseded success or failure is discarded. Failed locks or unwritten claims never authorize a request
139
+ - If the file is 150 seconds old (leader is closed, busy, or asleep), the first follower that obtains the mutex takes over: it re-reads the JSON, writes itself as the leader with a fresh timestamp, releases the mutex, then fetches from the server and publishes under the mutex after rechecking its claim. Its next refresh is two minutes later; JSON writes remain atomic renames
131
140
  - Claiming and publication use a non-waiting transaction in `mutex.sqlite`, through Node's built-in `node:sqlite` (Node ≥22.19.0, the existing package minimum). It stores no quota or leadership records and needs no extra package or service. The OS releases the lock when the connection closes or the process dies; there is no timeout-based lock stealing
132
141
  - Failures are written to the file with an exponential backoff (1 to 5 minutes; 5 to 30 minutes for HTTP 429) that applies to all instances
133
142
  - The last report stays visible for up to an hour; after that `error` is shown
134
143
 
135
144
  Coordination assumes a local filesystem and cooperating instances on the same machine. Never delete or replace `mutex.sqlite` while instances are running. Network requests do not hold the mutex; a process paused inside a short critical section keeps it until it resumes or exits, so other writers retry later without blocking the TUI. This preserves exclusion instead of stealing a live lock.
136
145
 
137
- **Upgrade:** Close all old instances before starting updated ones. The legacy `lock` files are ignored; old and new locking protocols must not run together. Cached `usage.json` data needs no migration.
146
+ **Upgrade:** Close all old instances before starting updated ones. The legacy `lock` files are ignored; old and new locking protocols must not run together. Cached `usage.json` data needs no migration. Restart all instances after updating so none retain an older polling interval or ignore the passive model-limit signal.
138
147
 
139
148
  ## Telegram Status Menu
140
149
 
@@ -0,0 +1,54 @@
1
+ /** Domain: model limit signal. Owns: narrow subscription-error recognition and near-empty window inference. Excludes: quota measurement, persistence, requests and rendering. */
2
+ import type { ExtensionContext, MessageEndEvent } from "@earendil-works/pi-coding-agent";
3
+ import type { ClaudeUsageReport } from "./usage.ts";
4
+
5
+ /** Empirical subscription budget estimate, not an Anthropic quota guarantee. */
6
+ const WEEKLY_TO_FIVE_HOUR_BUDGET_RATIO = 6;
7
+
8
+ /** Infer at most one near-empty window, comparing balances in five-hour budget units. */
9
+ export function applyModelRateLimit(report: ClaudeUsageReport): ClaudeUsageReport {
10
+ const primaryRemaining = report.primary ? 100 - report.primary.usedPercent : Infinity;
11
+ const secondaryRemaining = report.secondary ? 100 - report.secondary.usedPercent : Infinity;
12
+ if (primaryRemaining <= 0 || secondaryRemaining <= 0) return report;
13
+ const primaryNearEmpty = primaryRemaining < 5;
14
+ const secondaryNearEmpty = secondaryRemaining < 5;
15
+ if (!primaryNearEmpty && !secondaryNearEmpty) return report;
16
+ const selected = primaryNearEmpty && (!secondaryNearEmpty ||
17
+ primaryRemaining <= WEEKLY_TO_FIVE_HOUR_BUDGET_RATIO * secondaryRemaining)
18
+ ? "primary" : "secondary";
19
+ return { ...report, [selected]: { ...report[selected], usedPercent: 100 } };
20
+ }
21
+
22
+ const ACCOUNT_RATE_LIMIT_MESSAGE = "This request would exceed your account's rate limit. Please try again later.";
23
+
24
+ /** This display signal does not prove which quota window is exhausted. */
25
+ export function isSubscriptionRateLimit(
26
+ message: MessageEndEvent["message"],
27
+ model: ExtensionContext["model"],
28
+ usingOAuth: boolean,
29
+ ): boolean {
30
+ if (
31
+ !usingOAuth ||
32
+ !model ||
33
+ model.provider !== "anthropic" ||
34
+ model.api !== "anthropic-messages" ||
35
+ model.baseUrl.replace(/\/+$/, "") !== "https://api.anthropic.com" ||
36
+ !model.id.startsWith("claude-") ||
37
+ message.role !== "assistant" ||
38
+ message.provider !== model.provider ||
39
+ message.api !== model.api ||
40
+ message.model !== model.id ||
41
+ message.stopReason !== "error"
42
+ ) return false;
43
+ const match = message.errorMessage?.match(/^(?:Error:\s*)?429\s+(\{[\s\S]*\})\s*$/);
44
+ if (!match) return false;
45
+ try {
46
+ const body = JSON.parse(match[1]);
47
+ return body?.type === "error" &&
48
+ body.error?.type === "rate_limit_error" &&
49
+ typeof body.error.message === "string" &&
50
+ body.error.message.replace(/\s+/g, " ").trim() === ACCOUNT_RATE_LIMIT_MESSAGE;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
@@ -2,10 +2,11 @@
2
2
  import { join } from "node:path";
3
3
  import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
4
4
  import { isFastEnabled } from "./fast.ts";
5
- import { claimRefresh, isRefreshDue, nextRefreshAt, ownsRefreshClaim, publishRefresh, readState, MIN_ATTEMPT_GAP_MS, type SharedState } from "./usage-store.ts";
5
+ import { claimRefresh, isRefreshDue, nextRefreshAt, ownsRefreshClaim, publishRefresh, readState, recordModelRateLimit, MIN_ATTEMPT_GAP_MS, type SharedState } from "./usage-store.ts";
6
6
  import { isAnthropicModel, type ClaudeUsageModel, type ClaudeUsageReport } from "./usage.ts";
7
7
  import { DUAL_BAR_WIDTH, formatClaudeUsageBar, formatClaudeUsageStatusline, formatStatuslineLoading, formatStatuslineProblem, nextResetCountdownDelayMs } from "./status-format.ts";
8
8
  import { queryUsage } from "./query.ts";
9
+ import { applyModelRateLimit, isSubscriptionRateLimit } from "./model-limit.ts";
9
10
  import { claudeUsageTelegramStatusLine, registerClaudeUsageTelegramStatusLine } from "./telegram.ts";
10
11
 
11
12
  const DEFAULT_TIMEOUT_MS = 15_000;
@@ -187,12 +188,12 @@ export function createClaudeUsageStatus(pi: ExtensionAPI) {
187
188
  state?.report &&
188
189
  state.updatedAt !== undefined &&
189
190
  Date.now() - state.updatedAt < STALE_REPORT_MAX_AGE_MS
190
- ? state.report
191
+ ? state.modelRateLimitedAt !== undefined ? applyModelRateLimit(state.report) : state.report
191
192
  : undefined;
192
193
  if (usable) {
193
- const key = `report:${state?.updatedAt}`;
194
+ const key = `report:${state?.updatedAt}:${state?.modelRateLimitedAt ?? ""}`;
194
195
  if (!force && shown.key === key) return;
195
- const blink = shown.report
196
+ const blink = state?.modelRateLimitedAt === undefined && shown.report
196
197
  ? formatClaudeUsageBar(shown.report) !== formatClaudeUsageBar(usable)
197
198
  : false;
198
199
  shown = { key, report: usable, updatedAt: state?.updatedAt };
@@ -210,7 +211,7 @@ export function createClaudeUsageStatus(pi: ExtensionAPI) {
210
211
  };
211
212
 
212
213
  /**
213
- * When this instance is due (leader after 3 minutes, anyone after 210 seconds) it
214
+ * When this instance is due (leader after 2 minutes, anyone after 150 seconds) it
214
215
  * claims leadership under the lock (owner and timestamp, so no other
215
216
  * instance is due), then fetches, then stamps the outcome. Failures are
216
217
  * published with a backoff so other instances do not pile on.
@@ -333,6 +334,17 @@ export function createClaudeUsageStatus(pi: ExtensionAPI) {
333
334
  }
334
335
  });
335
336
 
337
+ pi.on("message_end", (event, ctx) => {
338
+ const message = event.message;
339
+ if (message.role !== "assistant" || message.provider !== "anthropic" || message.stopReason !== "error") return;
340
+ const model = ctx.modelRegistry.find(message.provider, message.model);
341
+ if (!model || !isSubscriptionRateLimit(message, model, ctx.modelRegistry.isUsingOAuth(model))) return;
342
+ if (recordModelRateLimit(stateDir) && isAnthropicModel(ctx.model)) {
343
+ syncFast(ctx.model);
344
+ renderState(ctx, loadState(), true);
345
+ }
346
+ });
347
+
336
348
  pi.on("session_shutdown", (_event, ctx) => {
337
349
  clearUsageStatusline(ctx);
338
350
  unregisterTelegramStatusLine?.();
@@ -4,6 +4,7 @@ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { DatabaseSync } from "node:sqlite";
6
6
  import type { ClaudeUsageReport } from "./usage.ts";
7
+ import { applyModelRateLimit } from "./model-limit.ts";
7
8
 
8
9
  // --- Shared Refresh ---
9
10
 
@@ -17,7 +18,7 @@ import type { ClaudeUsageReport } from "./usage.ts";
17
18
  * atomic renames; short critical sections use an OS-backed SQLite mutex.
18
19
  */
19
20
 
20
- export const LEADER_INTERVAL_MS = 3 * 60_000;
21
+ export const LEADER_INTERVAL_MS = 2 * 60_000;
21
22
  /** The leader is considered gone after missing its slot by 30 seconds. */
22
23
  export const TAKEOVER_AFTER_MS = LEADER_INTERVAL_MS + 30_000;
23
24
  /** Minimum pause between two fetch attempts of the same instance. */
@@ -44,6 +45,8 @@ export type SharedState = {
44
45
  failures?: number;
45
46
  /** No instance should fetch before this time. */
46
47
  retryNotBefore?: number;
48
+ /** Passive model-error signal; never replaces the measured report or its timestamp. */
49
+ modelRateLimitedAt?: number;
47
50
  };
48
51
 
49
52
  export function readState(dir: string): SharedState | undefined {
@@ -126,6 +129,19 @@ export function ownsRefreshClaim(
126
129
  return matchesClaim(current, claim, now ?? Date.now());
127
130
  }
128
131
 
132
+ /** A passive observation changes neither refresh ownership nor the shared retry deadline. */
133
+ export function recordModelRateLimit(dir: string, now?: number): boolean {
134
+ const release = tryAcquireLock(dir);
135
+ if (!release) return false;
136
+ try {
137
+ const current = readState(dir);
138
+ if (!current?.report || applyModelRateLimit(current.report) === current.report) return false;
139
+ return writeState(dir, { ...current, modelRateLimitedAt: now ?? Date.now() });
140
+ } finally {
141
+ release();
142
+ }
143
+ }
144
+
129
145
  /** A late success OR failure must not overwrite a successor's state. */
130
146
  export function publishRefresh(
131
147
  dir: string,
@@ -145,6 +161,10 @@ export function publishRefresh(
145
161
  claimedAt: at,
146
162
  updatedAt: at,
147
163
  report: outcome.report,
164
+ // A request already in flight when the model failed cannot clear its signal.
165
+ ...(current?.modelRateLimitedAt !== undefined &&
166
+ current.modelRateLimitedAt >= (current.claimedAt ?? Infinity)
167
+ ? { modelRateLimitedAt: current.modelRateLimitedAt } : {}),
148
168
  });
149
169
  }
150
170
  const failures = (current?.failures ?? 0) + 1;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-claude-usage",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "private": false,
5
5
  "description": "Minimal Pi extension that shows Anthropic Claude subscription usage limits using Pi OAuth auth",
6
6
  "keywords": [
@@ -13,7 +13,7 @@ The [relocation ledger](docs/agent-contract-relocation.md) maps every pre-compac
13
13
  - Artifacts name exact registered source paths; observe only regular non-symlink files, never discover directories or read unrelated bodies. Preserve hidden per-scope provenance, exact-owner compilation, stable fingerprint checks and separate Skill hashes; unavailable sources are not proof of deletion. See [artifact routing](docs/architecture.md#artifact-routing).
14
14
  - Preserve session config/runtime, per-scope metadata, lineage and provenance outside model-patchable state; decode legacy mode evidence read-only, never mix representations or normalize storage eagerly. See [storage and identity](docs/architecture.md#storage-and-identity) and [mode compatibility](docs/compatibility.md#mode-configuration-compatibility).
15
15
  - Keep one opaque composed causal lineage, anchored checkpoint/tail materialization, configured hot-history folding and independent owner revisions; never invent earlier history or rebuild discarded offsets. Historical reads are observational, and `effective[n]`/owner materializations select the same causal boundary. See [temporal model](docs/architecture.md#temporal-model) and [model tools](docs/architecture.md#model-tools).
16
- - Keep the [internal semantic-module contract](docs/semantic-module-contract.md) package-owned and separate from storage/native authority. Module candidates are not accepted publications; immutable means main-agent read-only, not producer-frozen. Preserve the single intent-first engine and current dialect; no runtime discovery/public ABI or automatic inference follows from a module identity.
16
+ - Keep the [internal semantic-module contract](docs/semantic-module-contract.md) package-owned and separate from storage/native authority. Module candidates are not accepted publications; immutable means main-agent read-only, not producer-frozen. Preserve the single intent-first engine and current dialect; no runtime discovery, public registration authority or automatic inference follows from an internal module identity.
17
17
  - Keep the [public module registration boundary](docs/public-module-contract.md) native-host-scoped and revocable. A registration/catalog grants no source, private or publication authority. Follow its explicit compatibility decision before admitting external stored roots; unavailable-module data must never be dropped through filtering or normalization. Pi remains the loader.
18
18
 
19
19
  ## Publication and lifecycle