@teamvibe/poller 0.8.4-rc.1 → 0.8.4-rc.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.
package/README.md CHANGED
@@ -74,23 +74,23 @@ All configuration is via environment variables (or `~/.teamvibe/.env`):
74
74
  |----------|----------|---------|-------------|
75
75
  | `TEAMVIBE_POLLER_TOKEN` | Yes | — | Your poller authentication token |
76
76
  | `CLAUDE_CODE_OAUTH_TOKEN` | No | — | Claude Code OAuth token (from `claude setup-token`) |
77
- | `EXPOSE_CLAUDE_TOKEN_TO_AGENT` | No | `false` | Opt in per poller to exposing the session token to agent subprocesses; accepts `1`/`true`/`yes`/`on` (case-insensitive) |
78
- | `TEAMVIBE_CLAUDE_OAUTH_TOKEN` | Injected | Unset | In the poller-injected env: with opt-in, the selected pool account's token, otherwise the poller's non-empty `CLAUDE_CODE_OAUTH_TOKEN`; inherited aliases are cleared. Claude settings can override this |
79
- | `TEAMVIBE_POOL_ACCOUNT_ID` | Injected | Unset | In the poller-injected env: with opt-in and a selected pool account, its account ID (no secret); absent without a pool account. Claude settings can override this |
77
+ | `EXPOSE_CLAUDE_TOKEN_TO_AGENT` | No | `true` | Expose the session token to agent subprocesses by default; only `0`/`false`/`no`/`off` (case-insensitive) opt out. Unset or any other value is on |
78
+ | `TEAMVIBE_CLAUDE_OAUTH_TOKEN` | Injected | Session token | In the poller-injected env: the selected pool account's token or the poller's non-empty `CLAUDE_CODE_OAUTH_TOKEN`; unset on explicit opt-out or without a token. Inherited aliases are cleared. Claude settings can override this |
79
+ | `TEAMVIBE_POOL_ACCOUNT_ID` | Injected | Selected account ID | In the poller-injected env: the selected pool account ID (no secret); unset on explicit opt-out or without a pool account. Claude settings can override this |
80
80
  | `TEAMVIBE_API_URL` | No | `https://poller.api.teamvibe.ai` | API endpoint |
81
81
  | `MAX_CONCURRENT_SESSIONS` | No | `5` | Max parallel Claude sessions |
82
82
  | `CLAUDE_CLI_PATH` | No | `claude` | Path to Claude Code CLI |
83
83
  | `TEAMVIBE_DATA_DIR` | No | `~/.teamvibe` | Data directory for brains and state |
84
84
 
85
- Set `EXPOSE_CLAUDE_TOKEN_TO_AGENT=true` in the poller's environment to let agents run nested Claude sessions on the current session's account. Claude CLI strips `CLAUDE_CODE_OAUTH_TOKEN` from Bash subprocess environments by name, so agents can restore it from the alias:
85
+ Agents can run nested Claude sessions on the current session's account by default, for both pool and non-pool sessions. Set `EXPOSE_CLAUDE_TOKEN_TO_AGENT=false` in the poller's environment to opt out (`0`, `no`, and `off` also disable it, case-insensitively); unset or any other value keeps it on. Claude CLI strips `CLAUDE_CODE_OAUTH_TOKEN` from Bash subprocess environments by name, so agents can restore it from the alias:
86
86
 
87
87
  ```bash
88
88
  CLAUDE_CODE_OAUTH_TOKEN="$TEAMVIBE_CLAUDE_OAUTH_TOKEN" claude -p "…"
89
89
  ```
90
90
 
91
- In the environment the poller injects, the alias and account ID follow the selected account on resume and failover; without opt-in, the poller leaves both variables unset. **This guarantee applies only to the injected process environment.** Claude CLI subsequently applies settings-file `env` entries: a brain's `CLAUDE_CONFIG_DIR/settings.json` can restore either variable when the flag is off or override it with a stale value when the flag is on. Project `.claude/settings.json` and `.claude/settings.local.json` can also override them. `settings.local.json` is a project source, not a user file next to `CLAUDE_CONFIG_DIR/settings.json`; recent CLI versions also read project-local settings from the main checkout for worktree sessions ([Claude settings sources](https://code.claude.com/docs/en/settings)). Before each spawn, the poller emits one warning listing the keys and files defining either variable in these settings sources, never their values. It never edits brain settings. Remove conflicting entries yourself to preserve the injected values in the agent's environment.
91
+ In the environment the poller injects, the alias and account ID follow the selected account on resume and failover; with explicit opt-out, the poller leaves both variables unset. **This guarantee applies only to the injected process environment.** Claude CLI subsequently applies settings-file `env` entries: a brain's `CLAUDE_CONFIG_DIR/settings.json` can restore either variable when the flag is off or override it with a stale value when the flag is on. Project `.claude/settings.json` and `.claude/settings.local.json` can also override them. `settings.local.json` is a project source, not a user file next to `CLAUDE_CONFIG_DIR/settings.json`; recent CLI versions also read project-local settings from the main checkout for worktree sessions ([Claude settings sources](https://code.claude.com/docs/en/settings)). Before each spawn, the poller emits one warning listing the keys and files defining either variable in these settings sources, never their values. It never edits brain settings. Remove conflicting entries yourself to preserve the injected values in the agent's environment.
92
92
 
93
- **Security trade-off:** with opt-in, the agent and its subprocesses can read and use the token, bypassing Claude CLI's credential filtering. Enable this only for agents you trust with that credential. With no settings override, nested sessions use the selected session account; this option adds no nested-session usage accounting. Poller-side Claude output logs, returned output/errors, typing status and fallback Slack replies redact the actual injected Claude token and alias values, plus assignments to either token variable name, whether or not opt-in is enabled. This does not prevent agents from using credentials or sending them directly through their own tools.
93
+ **Security trade-off:** by default, the agent and its subprocesses can read and use the token, bypassing Claude CLI's credential filtering. Opt out for agents you do not trust with that credential. With no settings override, nested sessions use the selected session account; this option adds no nested-session usage accounting. Poller-side Claude output logs, returned output/errors, typing status and fallback Slack replies redact the actual injected Claude token and alias values, plus assignments to either token variable name, whether exposure is enabled or explicitly disabled. This does not prevent agents from using credentials or sending them directly through their own tools.
94
94
 
95
95
  ## How It Works
96
96
 
@@ -9,7 +9,7 @@ export interface ClaudeRateLimitWindow {
9
9
  export interface ClaudeRateLimitInfo extends ClaudeRateLimitWindow {
10
10
  status?: string;
11
11
  rateLimitType?: string;
12
- unifiedWindows?: Partial<Record<ClaudeQuotaWindow, ClaudeRateLimitWindow>>;
12
+ unifiedWindows?: Partial<Record<string, ClaudeRateLimitWindow>>;
13
13
  overageStatus?: string;
14
14
  overageDisabledReason?: string;
15
15
  isUsingOverage?: boolean;
@@ -32,7 +32,9 @@ export function parseClaudeRateLimitInfo(value) {
32
32
  const windows = record(obj['unifiedWindows']);
33
33
  if (windows) {
34
34
  const parsed = {};
35
- for (const key of ['five_hour', 'seven_day']) {
35
+ for (const key of Object.keys(windows).slice(0, 128)) {
36
+ if (key.length > 128 || !/^(five_hour|seven_day)(?:_[a-z_]+)?$/.test(key))
37
+ continue;
36
38
  const data = record(windows[key]);
37
39
  if (data)
38
40
  parsed[key] = windowInfo(data);
@@ -460,7 +460,7 @@ export function sweepProcessGroup(proc, graceMs, options = {}) {
460
460
  * would look like if left uncapped.
461
461
  */
462
462
  export function buildSpawnEnv(params) {
463
- const exposeToken = /^(1|true|yes|on)$/i.test(params.processEnv['EXPOSE_CLAUDE_TOKEN_TO_AGENT'] ?? '');
463
+ const exposeToken = !/^(0|false|no|off)$/i.test(params.processEnv['EXPOSE_CLAUDE_TOKEN_TO_AGENT'] ?? '');
464
464
  const accountToken = params.subscriptionAccount?.credential.unwrap();
465
465
  const sessionToken = accountToken ?? params.processEnv['CLAUDE_CODE_OAUTH_TOKEN'];
466
466
  return {
@@ -513,7 +513,7 @@ export function buildSpawnEnv(params) {
513
513
  // watched after the session (and worktree) is gone. See tv#265.
514
514
  POLLER_BRAIN_PATH: params.brainPath,
515
515
  // Claude strips its own token name from Bash subprocesses. This alias
516
- // deliberately opts out of that protection, only at the poller level.
516
+ // exposes the session token by default; the poller can explicitly opt out.
517
517
  // Explicit undefined values also clear inherited/overlay aliases.
518
518
  TEAMVIBE_CLAUDE_OAUTH_TOKEN: exposeToken ? sessionToken || undefined : undefined,
519
519
  TEAMVIBE_POOL_ACCOUNT_ID: exposeToken ? params.subscriptionAccount?.accountId : undefined,
@@ -1588,7 +1588,9 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1588
1588
  // fallback must report the fresh attempt's delivery flags independently.
1589
1589
  if (!runtime?.resumePending)
1590
1590
  sawSendMessageCall = false;
1591
- const result = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, id, runtime?.resumePending ? false : first, lastMessageTs, onMessageSent, typingIndicator, runtime);
1591
+ const spawned = await runClaudeCode(msg, sessionLog, effectiveCwd, directory, id, runtime?.resumePending ? false : first, lastMessageTs, onMessageSent, typingIndicator, runtime);
1592
+ const result = prepared?.account && spawned.rateLimitInfo?.status === 'rejected'
1593
+ ? { ...spawned, success: false } : spawned;
1592
1594
  sawSendMessageCall ||= result.sawSendMessageCall === true;
1593
1595
  if (prepared?.account) {
1594
1596
  if (poolAttempts.length >= 1000)
@@ -1622,7 +1624,7 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1622
1624
  retainWorktree = false;
1623
1625
  return result;
1624
1626
  }
1625
- if (result.quota && !result.killedByPoller) {
1627
+ if ((result.quota || result.rateLimitInfo?.status === 'rejected') && !result.killedByPoller) {
1626
1628
  runtime.resumePending = true;
1627
1629
  retainWorktree = true;
1628
1630
  }
@@ -1645,7 +1647,7 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1645
1647
  let switched = false;
1646
1648
  const finishQuota = async (initial) => {
1647
1649
  let result = initial;
1648
- while (prepared?.account && result.quota && !result.killedByPoller && poolRuntime && runtime) {
1650
+ while (prepared?.account && (result.quota || result.rateLimitInfo?.status === 'rejected') && !result.killedByPoller && poolRuntime && runtime) {
1649
1651
  attemptedAccounts.add(prepared.account.accountId);
1650
1652
  if (poolRuntime.shouldStop() || attemptedAccounts.size >= 1000)
1651
1653
  throw new PoolDeferredError();
@@ -1653,7 +1655,8 @@ export async function spawnPoolClaudeCode(msg, sessionLog, selectedDirectory, se
1653
1655
  logPoolFetch(sessionLog, prepared.pool);
1654
1656
  if (prepared.pool.kind === 'ready')
1655
1657
  await emitCandidateUsage(msg, prepared.pool.accounts, prepared.store);
1656
- // Reactive evidence was persisted by attempt() before choosing another account.
1658
+ // Quota and passive rejection evidence were persisted before selection.
1659
+ // The chain also excludes attempted accounts even if a concurrent observation replaces that evidence.
1657
1660
  if (prepared.pool.kind === 'ready')
1658
1661
  for (const account of prepared.pool.accounts) {
1659
1662
  if (attemptedAccounts.has(account.accountId))
@@ -3,7 +3,7 @@ import { type StoredAccountUsage, type SubscriptionUsageStore } from './subscrip
3
3
  import type { PoolAttempt } from './subscription-runtime.js';
4
4
  import type { SessionMetrics, TeamVibeQueueMessage } from './types.js';
5
5
  /** Explicit transport DTO: never serialize a pool account/credential envelope. */
6
- export declare function accountUsageTelemetry(account: SubscriptionAccount, state: StoredAccountUsage): {
6
+ export declare function accountUsageTelemetry(account: SubscriptionAccount, state: StoredAccountUsage, reportedAt?: number): {
7
7
  reactive: {
8
8
  observedAt: number;
9
9
  window: import("./claude-rate-limit.js").ClaudeQuotaWindow | undefined;
@@ -31,10 +31,11 @@ export declare function poolRunTelemetry(attempts: readonly PoolAttempt[], quota
31
31
  score?: number | undefined;
32
32
  reason?: "headroom_per_hour" | undefined;
33
33
  rateWindow?: {
34
- window: "five_hour" | "seven_day";
35
34
  scope: "opus" | "haiku" | "global" | "sonnet" | "fable" | "uncertain";
36
35
  percent: number | undefined;
37
36
  resetAt: number | undefined;
37
+ kind?: "seven_day_overage_included" | "legacy-uncertain-seven-day" | undefined;
38
+ window: "five_hour" | "seven_day";
38
39
  } | undefined;
39
40
  accountId: string;
40
41
  accountLabel: string;
@@ -48,10 +49,11 @@ export declare function poolRunTelemetry(attempts: readonly PoolAttempt[], quota
48
49
  score?: number | undefined;
49
50
  reason?: "headroom_per_hour" | undefined;
50
51
  rateWindow?: {
51
- window: "five_hour" | "seven_day";
52
52
  scope: "opus" | "haiku" | "global" | "sonnet" | "fable" | "uncertain";
53
53
  percent: number | undefined;
54
54
  resetAt: number | undefined;
55
+ kind?: "seven_day_overage_included" | "legacy-uncertain-seven-day" | undefined;
56
+ window: "five_hour" | "seven_day";
55
57
  } | undefined;
56
58
  accountId?: string | undefined;
57
59
  accountLabel?: string | undefined;
@@ -1,6 +1,6 @@
1
1
  import { emitEvent } from './event-queue.js';
2
- import { normalizePassiveUsage, reactiveEligibleAt, } from './subscription-selection.js';
3
- import { safeQuota, safeRateLimitInfo, } from './subscription-usage-store.js';
2
+ import { reactiveEligibleAt } from './subscription-selection.js';
3
+ import { usageStateForSelection, safeQuota, safeRateLimitInfo, } from './subscription-usage-store.js';
4
4
  function safeText(value) {
5
5
  return value && value.length <= 128 && !/sk-ant-|Bearer\s|[\r\n]/i.test(value)
6
6
  ? value
@@ -30,18 +30,12 @@ function safeMetrics(metrics) {
30
30
  return result;
31
31
  }
32
32
  /** Explicit transport DTO: never serialize a pool account/credential envelope. */
33
- export function accountUsageTelemetry(account, state) {
34
- const passive = state.passive
35
- ? normalizePassiveUsage(state.passive.rateLimitInfo, state.passive.observedAt)
36
- : undefined;
37
- const probe = state.probe?.usage;
38
- const usage = passive && (!probe || passive.observedAt >= probe.observedAt)
39
- ? passive
40
- : probe;
33
+ export function accountUsageTelemetry(account, state, reportedAt = Date.now()) {
34
+ const { usage } = usageStateForSelection(state, reportedAt);
41
35
  return {
42
36
  accountId: account.accountId,
43
37
  accountLabel: account.label ?? account.accountId,
44
- reportedAt: Date.now(),
38
+ reportedAt,
45
39
  ...(usage ? { usage } : {}),
46
40
  ...(state.probe
47
41
  ? {
@@ -92,6 +86,7 @@ export function poolRunTelemetry(attempts, quotaDeferEpisodes = 0) {
92
86
  reason: a.reason,
93
87
  rateWindow: {
94
88
  window: a.rateWindow.window,
89
+ ...(a.rateWindow.kind ? { kind: a.rateWindow.kind } : {}),
95
90
  scope: a.rateWindow.scope,
96
91
  percent: a.rateWindow.percent,
97
92
  resetAt: a.rateWindow.resetAt,
@@ -0,0 +1,2 @@
1
+ /** Redact credentials in diagnostics without initializing poller authentication. */
2
+ export declare function redactStartupText(text: string, configuredTokens?: readonly string[]): string;
@@ -0,0 +1,15 @@
1
+ /** Redact credentials in diagnostics without initializing poller authentication. */
2
+ export function redactStartupText(text, configuredTokens = []) {
3
+ let safe = text
4
+ .replace(/https?:\/\/[^\s/@]+@/gi, 'https://[REDACTED]@')
5
+ .replace(/\b(?:Authorization\s*:\s*)?Bearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]')
6
+ .replace(/\bx-access-token\s*:\s*[^\s/@,;]+/gi, 'x-access-token:[REDACTED]')
7
+ // Cover both Claude's token name and the opt-in subprocess alias in
8
+ // shell assignments and JSON env diagnostics, even without a token list.
9
+ .replace(/(\b(?:CLAUDE_CODE_OAUTH_TOKEN|TEAMVIBE_CLAUDE_OAUTH_TOKEN)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'[^']*'|[^\s,;]+)/gi, '$1[REDACTED]');
10
+ for (const token of configuredTokens) {
11
+ if (token)
12
+ safe = safe.split(token).join('[REDACTED]');
13
+ }
14
+ return safe;
15
+ }
@@ -7,7 +7,7 @@ export declare class UnknownStartupFailureError extends Error {
7
7
  constructor(attempts: number);
8
8
  }
9
9
  /** The only formatter used for startup retry and terminal failure logs. */
10
- export declare function redactStartupText(text: string, configuredTokens?: readonly string[]): string;
10
+ export { redactStartupText } from './redact-text.js';
11
11
  export declare function startupFailureDetail(error: unknown, operation: string, configuredTokens?: readonly string[]): string;
12
12
  export declare function isPermanentStartupFailure(error: unknown): boolean;
13
13
  /** Returns false when shutdown cancels startup. Permanent errors propagate to the caller. */
@@ -1,3 +1,4 @@
1
+ import { redactStartupText } from './redact-text.js';
1
2
  import { AuthHttpError, StartupConfigurationError } from './auth-provider.js';
2
3
  export class UnknownStartupFailureError extends Error {
3
4
  attempts;
@@ -34,20 +35,7 @@ function isTransient(error) {
34
35
  return false;
35
36
  }
36
37
  /** The only formatter used for startup retry and terminal failure logs. */
37
- export function redactStartupText(text, configuredTokens = []) {
38
- let safe = text
39
- .replace(/https?:\/\/[^\s/@]+@/gi, 'https://[REDACTED]@')
40
- .replace(/\b(?:Authorization\s*:\s*)?Bearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]')
41
- .replace(/\bx-access-token\s*:\s*[^\s/@,;]+/gi, 'x-access-token:[REDACTED]')
42
- // Cover both Claude's token name and the opt-in subprocess alias in
43
- // shell assignments and JSON env diagnostics, even without a token list.
44
- .replace(/(\b(?:CLAUDE_CODE_OAUTH_TOKEN|TEAMVIBE_CLAUDE_OAUTH_TOKEN)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'[^']*'|[^\s,;]+)/gi, '$1[REDACTED]');
45
- for (const token of configuredTokens) {
46
- if (token)
47
- safe = safe.split(token).join('[REDACTED]');
48
- }
49
- return safe;
50
- }
38
+ export { redactStartupText } from './redact-text.js';
51
39
  export function startupFailureDetail(error, operation, configuredTokens = []) {
52
40
  let detail;
53
41
  if (error instanceof AuthHttpError)
@@ -71,5 +71,5 @@ export declare function preparePool(msg: TeamVibeQueueMessage, resolved: Resolve
71
71
  }>;
72
72
  export declare function accountPin(account: SubscriptionAccount, resolved: ResolvedPoolModel): SubscriptionPin;
73
73
  export declare function validateAccount(account: SubscriptionAccount, resolved: ResolvedPoolModel): Promise<void>;
74
- /** A snapshot TTL/recheck is not a provider reset. Report a reset only when reactive evidence establishes it. */
74
+ /** A snapshot TTL/recheck is not a provider reset. Explicit rejections also establish resets. */
75
75
  export declare function poolDeferredError(pool: PoolFetchResult<SubscriptionAccount>, resolved: ResolvedPoolModel, store: SubscriptionUsageStore, eligibleAt?: number, log?: PoolInfoLogger): Promise<PoolDeferredError>;
@@ -6,7 +6,7 @@ import { getPollerId } from './auth-provider.js';
6
6
  import { fetchSubscriptionPool } from './subscription-pool-client.js';
7
7
  import { selectPoolForResolvedModel } from './subscription-pool.js';
8
8
  import { FileSubscriptionUsageStore, usageStateForSelection } from './subscription-usage-store.js';
9
- import { PASSIVE_USAGE_TTL_MS, PROBE_USAGE_TTL_MS, reactiveEligibleAt, USAGE_THRESHOLD_PERCENT, POOL_RECHECK_MS } from './subscription-selection.js';
9
+ import { normalizePassiveUsage, selectSubscriptionAccount, PASSIVE_USAGE_TTL_MS, PROBE_USAGE_TTL_MS, reactiveEligibleAt, USAGE_THRESHOLD_PERCENT, POOL_RECHECK_MS } from './subscription-selection.js';
10
10
  export class PoolDeferredError extends Error {
11
11
  eligibleAt;
12
12
  resetAt;
@@ -64,7 +64,7 @@ export async function validateAccount(account, resolved) {
64
64
  throw new PoolDeferredError(selected.kind === 'deferred' ? selected.eligibleAt : undefined);
65
65
  }
66
66
  /** Message-only evidence; never changes selection, eligibility or durable pause state. */
67
- async function poolUsageNotice(pool, store) {
67
+ async function poolUsageNotice(pool, resolved, store) {
68
68
  if (pool.kind !== 'ready')
69
69
  return {};
70
70
  const now = Date.now();
@@ -75,7 +75,12 @@ async function poolUsageNotice(pool, store) {
75
75
  const ttl = usage?.source === 'passive' ? PASSIVE_USAGE_TTL_MS : PROBE_USAGE_TTL_MS;
76
76
  const windows = usage && usage.observedAt <= now && now < usage.observedAt + ttl
77
77
  ? usage.windows.filter(w => w.resetAt === undefined || w.resetAt > now) : [];
78
- const rejected = state.reactive?.some(r => r.observedAt <= now && reactiveEligibleAt(r) > now) ?? false;
78
+ const rejection = selectSubscriptionAccount({
79
+ pool: { kind: 'ready', accounts: [account] },
80
+ states: { [account.accountId]: { usage: usage ? { ...usage, windows: usage.windows.filter(w => w.status === 'rejected') } : undefined } },
81
+ model: resolved.model, now,
82
+ });
83
+ const rejected = rejection.kind === 'deferred' || (state.reactive?.some(r => r.observedAt <= now && reactiveEligibleAt(r) > now) ?? false);
79
84
  const blockers = windows.filter(w => w.percent !== undefined && w.percent >= (account.useFullQuota ? 100 : USAGE_THRESHOLD_PERCENT));
80
85
  const resets = blockers.map(w => w.resetAt);
81
86
  return {
@@ -94,9 +99,9 @@ async function poolUsageNotice(pool, store) {
94
99
  return {};
95
100
  }
96
101
  }
97
- /** A snapshot TTL/recheck is not a provider reset. Report a reset only when reactive evidence establishes it. */
102
+ /** A snapshot TTL/recheck is not a provider reset. Explicit rejections also establish resets. */
98
103
  export async function poolDeferredError(pool, resolved, store, eligibleAt, log) {
99
- const notice = await poolUsageNotice(pool, store);
104
+ const notice = await poolUsageNotice(pool, resolved, store);
100
105
  let usageDeferred = eligibleAt !== undefined;
101
106
  const deferredError = (resetAt) => {
102
107
  const error = new PoolDeferredError(eligibleAt, resetAt);
@@ -115,6 +120,9 @@ export async function poolDeferredError(pool, resolved, store, eligibleAt, log)
115
120
  if (selected.kind !== 'deferred' || selected.eligibleAt !== eligibleAt)
116
121
  continue;
117
122
  const state = await store.read(account);
123
+ const usage = state.passive ? normalizePassiveUsage(state.passive.rateLimitInfo, state.passive.observedAt) : undefined;
124
+ if (usage?.windows.some(w => w.status === 'rejected' && w.resetAt === eligibleAt))
125
+ return deferredError(new Date(eligibleAt).toISOString());
118
126
  const live = state.reactive.filter(r => !r.quota.resetAt || Date.parse(r.quota.resetAt) > Date.now());
119
127
  if (live.length && live.every(r => r.quota.resetAt && Number.isFinite(Date.parse(r.quota.resetAt))) && live.some(r => Date.parse(r.quota.resetAt) === eligibleAt))
120
128
  return deferredError(new Date(eligibleAt).toISOString());
@@ -5,6 +5,8 @@ export declare const USAGE_THRESHOLD_PERCENT = 90;
5
5
  export declare const PASSIVE_USAGE_TTL_MS: number;
6
6
  export declare const PROBE_USAGE_TTL_MS = 60000;
7
7
  export declare const POOL_RECHECK_MS: number;
8
+ /** Safety hold from the rejection observation when the provider gives no reset. */
9
+ export declare const REJECTED_BLOCK_MS: number;
8
10
  export interface AccountIdentity {
9
11
  workspaceId: string;
10
12
  accountId: string;
@@ -33,7 +35,12 @@ export type PoolFetchResult<A extends AccountIdentity = AccountIdentity> = {
33
35
  export interface UsageWindow {
34
36
  window: 'five_hour' | 'seven_day';
35
37
  scope: 'global' | 'sonnet' | 'opus' | 'haiku' | 'fable' | 'uncertain';
38
+ /** CLI kind, or the server compatibility provenance for a legacy Fable allowance. */
39
+ kind?: 'seven_day_overage_included' | 'legacy-uncertain-seven-day';
36
40
  status?: 'allowed' | 'allowed_warning' | 'rejected';
41
+ /** Retained rejections keep their own clock when merged into newer measurements. */
42
+ observedAt?: number;
43
+ source?: 'passive' | 'probe';
37
44
  percent?: number;
38
45
  resetAt?: number;
39
46
  }
@@ -42,6 +49,8 @@ export interface UsageObservation {
42
49
  observedAt: number;
43
50
  windows: readonly UsageWindow[];
44
51
  }
52
+ /** Latest measurements plus durable rejections; omission or success never clears a hold. */
53
+ export declare function mergeUsageObservations(observations: readonly (UsageObservation | undefined)[], now: number): UsageObservation | undefined;
45
54
  export interface ReactiveExhaustion {
46
55
  observedAt: number;
47
56
  quota: ClaudeQuotaFailure;
@@ -3,6 +3,40 @@ export const USAGE_THRESHOLD_PERCENT = 90;
3
3
  export const PASSIVE_USAGE_TTL_MS = 5 * 60_000;
4
4
  export const PROBE_USAGE_TTL_MS = 60_000;
5
5
  export const POOL_RECHECK_MS = 15 * 60_000;
6
+ /** Safety hold from the rejection observation when the provider gives no reset. */
7
+ export const REJECTED_BLOCK_MS = 15 * 60_000;
8
+ /** Latest measurements plus durable rejections; omission or success never clears a hold. */
9
+ export function mergeUsageObservations(observations, now) {
10
+ let latest;
11
+ const rejected = new Map();
12
+ for (const usage of observations) {
13
+ if (!usage || usage.observedAt > now)
14
+ continue;
15
+ if (!latest || usage.observedAt >= latest.observedAt)
16
+ latest = usage;
17
+ for (const w of usage.windows) {
18
+ const observedAt = w.observedAt ?? usage.observedAt;
19
+ if (w.status !== 'rejected' || observedAt > now)
20
+ continue;
21
+ const key = `${w.window}:${w.scope}`;
22
+ const previous = rejected.get(key);
23
+ if (!previous || observedAt >= previous.observedAt)
24
+ rejected.set(key, { ...w, observedAt, source: w.source ?? usage.source });
25
+ }
26
+ }
27
+ if (!latest)
28
+ return undefined;
29
+ const active = [...rejected.values()].filter(w => now < (w.resetAt ?? w.observedAt + REJECTED_BLOCK_MS));
30
+ return {
31
+ ...latest,
32
+ windows: [
33
+ ...latest.windows.filter(w => w.status !== 'rejected' &&
34
+ (w.observedAt ?? latest.observedAt) === latest.observedAt &&
35
+ (w.source ?? latest.source) === latest.source).slice(0, 133 - active.length),
36
+ ...active,
37
+ ],
38
+ };
39
+ }
6
40
  export function record(value) {
7
41
  return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
8
42
  }
@@ -22,29 +56,24 @@ function date(value) {
22
56
  const ms = Date.parse(value);
23
57
  return Number.isFinite(ms) && ms > 0 ? ms : undefined;
24
58
  }
59
+ /** Undocumented CLI stream kinds: only explicit model names and the measured Fable alias. */
60
+ function passiveWindowKind(kind) {
61
+ if (kind === 'seven_day_overage_included')
62
+ return { window: 'seven_day', scope: 'fable', kind };
63
+ const legacy = kind.match(/^(five_hour|seven_day)(?:_(sonnet|opus|haiku|fable))?$/);
64
+ return {
65
+ window: legacy?.[1] === 'five_hour' ? 'five_hour' : 'seven_day',
66
+ scope: legacy ? (legacy[2] ?? 'global') : 'uncertain',
67
+ };
68
+ }
25
69
  export function normalizePassiveUsage(value, observedAt) {
26
70
  const info = record(value);
27
71
  const windows = [];
28
- const unified = record(info['unifiedWindows']);
29
- for (const window of ['five_hour', 'seven_day']) {
30
- const raw = unified[window];
31
- if (raw == null)
32
- continue;
33
- const item = record(raw);
34
- windows.push({ window, scope: 'global', percent: percent(item['utilization'], 1), resetAt: epoch(item['resetsAt']) });
35
- }
36
- const type = info['rateLimitType'];
37
- if (typeof type === 'string') {
38
- const legacy = type.match(/^(five_hour|seven_day)(?:_(sonnet|opus|haiku|fable))?$/);
39
- const candidate = {
40
- window: legacy?.[1] === 'five_hour' ? 'five_hour' : 'seven_day',
41
- scope: legacy ? (legacy[2] ?? 'global') : 'uncertain',
42
- percent: percent(info['utilization'], 1), resetAt: epoch(info['resetsAt']),
43
- ...(info['status'] === 'allowed' || info['status'] === 'allowed_warning' || info['status'] === 'rejected'
44
- ? { status: info['status'] } : {}),
45
- };
72
+ const add = (candidate) => {
46
73
  const existing = windows.find(w => w.window === candidate.window && w.scope === candidate.scope);
47
74
  if (existing) {
75
+ if (candidate.kind !== undefined)
76
+ existing.kind = candidate.kind;
48
77
  if (candidate.status !== undefined)
49
78
  existing.status = candidate.status;
50
79
  // Collapse the same quota while retaining conservative conflict evidence.
@@ -56,6 +85,21 @@ export function normalizePassiveUsage(value, observedAt) {
56
85
  else {
57
86
  windows.push(candidate);
58
87
  }
88
+ };
89
+ for (const [kind, raw] of Object.entries(record(info['unifiedWindows'])).slice(0, 128)) {
90
+ if (raw == null)
91
+ continue;
92
+ const item = record(raw);
93
+ add({ ...passiveWindowKind(kind), percent: percent(item['utilization'], 1), resetAt: epoch(item['resetsAt']) });
94
+ }
95
+ const type = info['rateLimitType'];
96
+ if (typeof type === 'string') {
97
+ add({
98
+ ...passiveWindowKind(type),
99
+ percent: percent(info['utilization'], 1), resetAt: epoch(info['resetsAt']),
100
+ ...(info['status'] === 'allowed' || info['status'] === 'allowed_warning' || info['status'] === 'rejected'
101
+ ? { status: info['status'] } : {}),
102
+ });
59
103
  }
60
104
  return { source: 'passive', observedAt, windows };
61
105
  }
@@ -143,9 +187,22 @@ export function selectSubscriptionAccount(input) {
143
187
  blockers.push(until);
144
188
  }
145
189
  const usage = state?.usage;
190
+ // A provider rejection outlives snapshot freshness. Never score it as headroom.
191
+ if (usage && usage.observedAt <= input.now) {
192
+ for (const w of usage.windows) {
193
+ if (w.status !== 'rejected' || !applicable(w.scope, input.model))
194
+ continue;
195
+ const observedAt = w.observedAt ?? usage.observedAt;
196
+ if (observedAt > input.now)
197
+ continue;
198
+ const until = w.resetAt ?? observedAt + REJECTED_BLOCK_MS;
199
+ if (input.now < until)
200
+ blockers.push(until);
201
+ }
202
+ }
146
203
  const expiry = usage ? usage.observedAt + (usage.source === 'passive' ? PASSIVE_USAGE_TTL_MS : PROBE_USAGE_TTL_MS) : 0;
147
204
  const fresh = usage && usage.observedAt <= input.now && input.now < expiry;
148
- const windows = fresh ? usage.windows.filter(w => applicable(w.scope, input.model) && (w.resetAt === undefined || w.resetAt > input.now)) : [];
205
+ const windows = fresh ? usage.windows.filter(w => w.status !== 'rejected' && (w.observedAt ?? usage.observedAt) === usage.observedAt && (w.source ?? usage.source) === usage.source && applicable(w.scope, input.model) && (w.resetAt === undefined || w.resetAt > input.now)) : [];
149
206
  const uncertain = windows.some(w => w.scope === 'uncertain' || (w.scope !== 'global' && !family(input.model)));
150
207
  for (const w of windows) {
151
208
  if (w.percent !== undefined && w.percent >= (account.useFullQuota ? 100 : USAGE_THRESHOLD_PERCENT))
@@ -9,6 +9,8 @@ export interface ProbeObservation {
9
9
  usage?: UsageObservation;
10
10
  }
11
11
  export interface StoredAccountUsage {
12
+ /** Latest measured snapshot plus active rejected windows, persisted across observations. */
13
+ usage?: UsageObservation;
12
14
  passive?: {
13
15
  rateLimitInfo: ClaudeRateLimitInfo;
14
16
  observedAt: number;
@@ -1,13 +1,18 @@
1
+ import { redactStartupText } from './redact-text.js';
1
2
  import { createHash, randomUUID } from 'node:crypto';
2
3
  import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
4
  import { join, resolve } from 'node:path';
4
5
  import { parseClaudeRateLimitInfo } from './claude-rate-limit.js';
5
- import { normalizePassiveUsage, record, reactiveEligibleAt, PASSIVE_USAGE_TTL_MS } from './subscription-selection.js';
6
+ import { normalizePassiveUsage, mergeUsageObservations, record, reactiveEligibleAt } from './subscription-selection.js';
6
7
  export function usageStateForSelection(state, now) {
7
- const passive = state.passive ? normalizePassiveUsage(state.passive.rateLimitInfo, state.passive.observedAt) : undefined;
8
- // Passive is primary while fresh; a completed optional probe may supplement a stale/missing snapshot.
9
- const fresh = passive && passive.observedAt <= now && now < passive.observedAt + PASSIVE_USAGE_TTL_MS;
10
- return { usage: fresh ? passive : state.probe?.status === 'ok' ? state.probe.usage : passive, reactive: state.reactive };
8
+ return {
9
+ usage: mergeUsageObservations([
10
+ state.usage,
11
+ state.passive ? normalizePassiveUsage(state.passive.rateLimitInfo, state.passive.observedAt) : undefined,
12
+ state.probe?.status === 'ok' ? state.probe.usage : undefined,
13
+ ], now),
14
+ reactive: state.reactive,
15
+ };
11
16
  }
12
17
  export class SubscriptionUsageStoreError extends Error {
13
18
  constructor() { super('Subscription usage store unavailable'); this.name = 'SubscriptionUsageStoreError'; }
@@ -30,12 +35,20 @@ function validTime(now) {
30
35
  if (typeof now !== 'number' || !Number.isFinite(now) || now <= 0 || now > 253402300799000)
31
36
  throw new SubscriptionUsageStoreError();
32
37
  }
38
+ const loggedUnknownRateLimitKinds = new Set();
39
+ const knownRateLimitKind = /^(?:(five_hour|seven_day)(?:_(sonnet|opus|haiku|fable))?|seven_day_overage_included)$/;
33
40
  export function safeRateLimitInfo(info) {
34
41
  const parsed = parseClaudeRateLimitInfo(info) ?? {};
35
42
  // Never persist arbitrary status strings or incidental stream fields.
36
43
  if (parsed.status !== 'allowed' && parsed.status !== 'allowed_warning' && parsed.status !== 'rejected')
37
44
  delete parsed.status;
38
- if (parsed.rateLimitType && !/^(five_hour|seven_day)(?:_(sonnet|opus|haiku|fable))?$/.test(parsed.rateLimitType))
45
+ for (const kind of [parsed.rateLimitType, ...Object.keys(parsed.unifiedWindows ?? {})]) {
46
+ if (!kind || kind === 'uncertain' || knownRateLimitKind.test(kind) || !/^[a-z_]{1,128}$/.test(kind) || loggedUnknownRateLimitKinds.has(kind))
47
+ continue;
48
+ loggedUnknownRateLimitKinds.add(kind);
49
+ console.warn(redactStartupText(`[Pool] unknown rate limit kind=${kind}`));
50
+ }
51
+ if (parsed.rateLimitType && !knownRateLimitKind.test(parsed.rateLimitType))
39
52
  parsed.rateLimitType = 'uncertain';
40
53
  delete parsed.overageStatus;
41
54
  delete parsed.overageDisabledReason;
@@ -51,6 +64,28 @@ export function safeQuota(quota) {
51
64
  ...(Number.isFinite(reset) && reset > 0 ? { resetAt: new Date(reset).toISOString() } : {}),
52
65
  };
53
66
  }
67
+ function safeUsage(value, source) {
68
+ const usage = record(value);
69
+ if (typeof usage['observedAt'] !== 'number' || !Array.isArray(usage['windows']))
70
+ throw new SubscriptionUsageStoreError();
71
+ validTime(usage['observedAt']);
72
+ const windows = usage['windows'].slice(0, 133).map(raw => {
73
+ const w = record(raw);
74
+ const window = w['window'] === 'five_hour' ? 'five_hour' : 'seven_day';
75
+ const scope = typeof w['scope'] === 'string' && ['global', 'sonnet', 'opus', 'haiku', 'fable'].includes(w['scope']) ? w['scope'] : 'uncertain';
76
+ const percent = typeof w['percent'] === 'number' && Number.isFinite(w['percent']) && w['percent'] >= 0 && w['percent'] <= 100 ? w['percent'] : undefined;
77
+ const resetAt = typeof w['resetAt'] === 'number' && Number.isFinite(w['resetAt']) && w['resetAt'] > 0 && w['resetAt'] <= 253402300799000 ? w['resetAt'] : undefined;
78
+ const status = w['status'] === 'allowed' || w['status'] === 'allowed_warning' || w['status'] === 'rejected' ? w['status'] : undefined;
79
+ const observedAt = typeof w['observedAt'] === 'number' ? w['observedAt'] : undefined;
80
+ if (observedAt !== undefined)
81
+ validTime(observedAt);
82
+ const source = w['source'] === 'passive' || w['source'] === 'probe' ? w['source'] : undefined;
83
+ return { window, scope, ...(w['kind'] === 'seven_day_overage_included' ? { kind: w['kind'] } : {}), percent, resetAt, ...(status ? { status } : {}), ...(observedAt ? { observedAt } : {}), ...(source ? { source } : {}) };
84
+ });
85
+ if (usage['windows'].length > 133)
86
+ windows.push({ window: 'seven_day', scope: 'uncertain' });
87
+ return { source, observedAt: usage['observedAt'], windows };
88
+ }
54
89
  function safeProbe(value) {
55
90
  const obj = record(value);
56
91
  const status = obj['status'];
@@ -62,21 +97,7 @@ function safeProbe(value) {
62
97
  validTime(observedAt);
63
98
  const result = { status: status, observedAt };
64
99
  if (status === 'ok' && obj['usage']) {
65
- const usage = record(obj['usage']);
66
- if (typeof usage['observedAt'] !== 'number' || !Array.isArray(usage['windows']))
67
- throw new SubscriptionUsageStoreError();
68
- validTime(usage['observedAt']);
69
- const windows = usage['windows'].slice(0, 133).map(raw => {
70
- const w = record(raw);
71
- const window = w['window'] === 'five_hour' ? 'five_hour' : 'seven_day';
72
- const scope = typeof w['scope'] === 'string' && ['global', 'sonnet', 'opus', 'haiku', 'fable'].includes(w['scope']) ? w['scope'] : 'uncertain';
73
- const percent = typeof w['percent'] === 'number' && Number.isFinite(w['percent']) && w['percent'] >= 0 && w['percent'] <= 100 ? w['percent'] : undefined;
74
- const resetAt = typeof w['resetAt'] === 'number' && Number.isFinite(w['resetAt']) && w['resetAt'] > 0 && w['resetAt'] <= 253402300799000 ? w['resetAt'] : undefined;
75
- return { window, scope, percent, resetAt };
76
- });
77
- if (usage['windows'].length > 133)
78
- windows.push({ window: 'seven_day', scope: 'uncertain' });
79
- result.usage = { source: 'probe', observedAt: usage['observedAt'], windows };
100
+ result.usage = safeUsage(obj['usage'], 'probe');
80
101
  }
81
102
  return result;
82
103
  }
@@ -88,6 +109,9 @@ function safeVersion(value) {
88
109
  }
89
110
  if (value.probe)
90
111
  state.probe = safeProbe(value.probe);
112
+ if (value.usage) {
113
+ state.usage = safeUsage(value.usage, value.usage.source === 'passive' ? 'passive' : 'probe');
114
+ }
91
115
  return state;
92
116
  }
93
117
  function safeReactive(value) {
@@ -158,7 +182,9 @@ export class FileSubscriptionUsageStore {
158
182
  const state = safeVersion(await this.#load(path, {}));
159
183
  if (state.passive && state.passive.observedAt > observedAt && !options?.expireFutureObservations)
160
184
  return;
185
+ const previous = usageStateForSelection({ ...state, reactive: [] }, observedAt).usage;
161
186
  state.passive = { rateLimitInfo: safeRateLimitInfo(rateLimitInfo), observedAt };
187
+ state.usage = mergeUsageObservations([previous, normalizePassiveUsage(state.passive.rateLimitInfo, observedAt)], observedAt);
162
188
  await this.#save(path, state);
163
189
  });
164
190
  }
@@ -216,7 +242,11 @@ export class FileSubscriptionUsageStore {
216
242
  const path = this.#path(key, true);
217
243
  await serial(path, async () => {
218
244
  const state = safeVersion(await this.#load(path, {}));
245
+ // Network completion order must not discard a newer stored snapshot.
246
+ const mergeAt = Math.max(observation.observedAt, state.usage?.observedAt ?? 0, state.passive?.observedAt ?? 0);
247
+ const previous = usageStateForSelection({ ...state, reactive: [] }, mergeAt).usage;
219
248
  state.probe = safeProbe(observation);
249
+ state.usage = mergeUsageObservations([previous, state.probe.usage], mergeAt);
220
250
  await this.#save(path, state);
221
251
  });
222
252
  }
@@ -3,7 +3,7 @@ import { mkdtemp, rm, mkdir } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { normalizePassiveUsage } from './subscription-selection.js';
6
- import { safeRateLimitInfo, } from './subscription-usage-store.js';
6
+ import { safeRateLimitInfo, usageStateForSelection, } from './subscription-usage-store.js';
7
7
  import { parseClaudeRateLimitInfo, } from './claude-rate-limit.js';
8
8
  export function isUsageProbe(value) {
9
9
  return (!!value &&
@@ -176,7 +176,7 @@ export async function handleUsageProbe(job, options) {
176
176
  ...metadata,
177
177
  accountLabel: account.label ?? account.accountId,
178
178
  reportedAt: now,
179
- usage,
179
+ usage: usageStateForSelection(await options.store.read(account), now).usage,
180
180
  lastRateLimitInfo: safe,
181
181
  probeStatus: 'ok',
182
182
  probeObservedAt: now,
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.8.4-rc.1",
3
+ "version": "0.8.4-rc.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@teamvibe/poller",
9
- "version": "0.8.4-rc.1",
9
+ "version": "0.8.4-rc.3",
10
10
  "dependencies": {
11
11
  "@aws-sdk/client-dynamodb": "3.1108.0",
12
12
  "@aws-sdk/client-sqs": "3.1108.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teamvibe/poller",
3
- "version": "0.8.4-rc.1",
3
+ "version": "0.8.4-rc.3",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {