@zgltyq/pi-provider-claude 1.2.0 → 1.3.0

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 (3) hide show
  1. package/README.md +14 -0
  2. package/index.ts +84 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -91,6 +91,20 @@ the assistant message (`stopReason: "error"`) via `message_end`, including Claud
91
91
  is hit, the turn errors once, the pool flips (with a UI notification), and you just
92
92
  resend your message to continue on the other account.
93
93
 
94
+ **Background token refresh (v1.3.0):** OAuth access tokens are short-lived. An
95
+ account that sat idle (e.g. your fallback) would have a dead access token by the
96
+ time failover switched to it — producing a `401 authentication_error`. v1.3.0 adds:
97
+
98
+ - A `setInterval` keep-warm loop that refreshes **every** pooled account before it
99
+ expires, even when idle and not the active account (the timer is `unref()`'d so
100
+ it never blocks CLI commands like `pi update` from exiting).
101
+ - A wider 5-minute pre-expiry refresh buffer, and a full-pool refresh on
102
+ `session_start` / `before_agent_start` (not just the active account).
103
+ - Reactive 401 handling: if the server rejects the active token anyway, it is
104
+ force-refreshed in place (no account switch) so the resend succeeds. If the
105
+ refresh token itself was revoked, you're told to re-run `/login` +
106
+ `/claude-pool-add <label>`.
107
+
94
108
  It is **inert** unless `<agentDir>/claude-pool.json` exists with ≥2 accounts and
95
109
  `"enabled" !== false`.
96
110
 
package/index.ts CHANGED
@@ -291,12 +291,20 @@ const POOL_PATH = join(
291
291
  process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"),
292
292
  "claude-pool.json",
293
293
  );
294
- const POOL_REFRESH_BUFFER_MS = 60_000;
294
+ // Refresh an access token this long BEFORE it actually expires, so we never
295
+ // hand a request a token that's about to die. Generous because OAuth access
296
+ // tokens are short-lived and the failover target may have sat idle for days.
297
+ const POOL_REFRESH_BUFFER_MS = 5 * 60_000;
295
298
  const POOL_DEFAULT_COOLDOWN_MS = 5 * 60_000;
299
+ // Background keep-warm cadence: re-check every account on this interval even
300
+ // when idle / not the active one, so an account is never stale when we switch.
301
+ const POOL_BG_REFRESH_INTERVAL_MS = 4 * 60_000;
296
302
 
297
303
  let poolAccounts: PoolAccount[] = [];
298
304
  let poolActive = 0;
299
305
  let poolCooldownUntil: number[] = [];
306
+ let poolBgTimer: ReturnType<typeof setInterval> | undefined;
307
+ let poolRefreshInFlight = false;
300
308
 
301
309
  function poolLog(msg: string): void {
302
310
  writeDebugLog({ stage: "pool", time: new Date().toISOString(), msg });
@@ -363,9 +371,12 @@ function poolSelectActive(): void {
363
371
  }
364
372
  }
365
373
 
366
- async function poolEnsureFresh(i: number): Promise<void> {
374
+ // Low-level refresh of account `i`. Returns true on success. When `force` is
375
+ // false it skips accounts whose token is still comfortably valid.
376
+ async function poolRefreshAccount(i: number, force: boolean): Promise<boolean> {
367
377
  const acct = poolAccounts[i];
368
- if (!acct || acct.expires > Date.now() + POOL_REFRESH_BUFFER_MS) return;
378
+ if (!acct) return false;
379
+ if (!force && acct.expires > Date.now() + POOL_REFRESH_BUFFER_MS) return true;
369
380
  try {
370
381
  const oauth = (await import("@earendil-works/pi-ai/oauth")) as {
371
382
  refreshAnthropicToken: (
@@ -377,11 +388,39 @@ async function poolEnsureFresh(i: number): Promise<void> {
377
388
  acct.expires = next.expires;
378
389
  if (next.refresh) acct.refresh = next.refresh;
379
390
  persistPool();
380
- poolLog(`refreshed ${acct.label}`);
391
+ poolLog(`refreshed ${acct.label} (exp ${new Date(acct.expires).toISOString()})`);
392
+ return true;
381
393
  } catch (e) {
382
394
  poolLog(
383
395
  `refresh FAILED ${acct.label}: ${(e as Error)?.message ?? String(e)}`,
384
396
  );
397
+ return false;
398
+ }
399
+ }
400
+
401
+ // Refresh account `i` only if it is near (or past) expiry.
402
+ async function poolEnsureFresh(i: number): Promise<void> {
403
+ await poolRefreshAccount(i, false);
404
+ }
405
+
406
+ // Force a refresh of account `i` regardless of expiry (used on a 401, when the
407
+ // server rejected the token even though we thought it was still valid).
408
+ async function poolForceRefresh(i: number): Promise<boolean> {
409
+ return poolRefreshAccount(i, true);
410
+ }
411
+
412
+ // Background keep-warm: refresh EVERY pooled account whose token is near expiry,
413
+ // not just the active one. This is what guarantees the failover target is never
414
+ // stale when we switch to it after it sat unused for a long time.
415
+ async function poolRefreshAll(): Promise<void> {
416
+ if (poolRefreshInFlight) return; // never overlap refresh sweeps
417
+ poolRefreshInFlight = true;
418
+ try {
419
+ for (let i = 0; i < poolAccounts.length; i++) {
420
+ await poolEnsureFresh(i);
421
+ }
422
+ } finally {
423
+ poolRefreshInFlight = false;
385
424
  }
386
425
  }
387
426
 
@@ -415,6 +454,13 @@ function poolMarkRateLimitedUntil(until: number): void {
415
454
  const POOL_LIMIT_RE =
416
455
  /\b429\b|\b529\b|rate[ _-]?limit|usage[ _-]?limit|overloaded_error|quota/i;
417
456
 
457
+ // Expired / rejected credential detection (401). When the active account's
458
+ // token is rejected we force-refresh it in place so the resend succeeds — this
459
+ // is the symptom of switching to an account that sat idle until its access
460
+ // token died. Anchored to avoid colliding with the 429 message.
461
+ const POOL_AUTH_RE =
462
+ /authentication_error|invalid authentication credentials|\b401\b/i;
463
+
418
464
  // Claude subscription cap messages often carry a reset epoch after a pipe,
419
465
  // e.g. "Claude AI usage limit reached|1750000000".
420
466
  function poolParseResetEpoch(message: string): number | undefined {
@@ -544,7 +590,9 @@ function setupPool(pi: ExtensionAPI): void {
544
590
 
545
591
  const prep = async () => {
546
592
  poolSelectActive();
547
- await poolEnsureFresh(poolActive);
593
+ // Keep ALL accounts warm, not just the active one, so the failover target
594
+ // is ready the instant we switch to it.
595
+ await poolRefreshAll();
548
596
  };
549
597
  pi.on("session_start", async () => {
550
598
  await prep();
@@ -553,6 +601,20 @@ function setupPool(pi: ExtensionAPI): void {
553
601
  await prep();
554
602
  });
555
603
 
604
+ // Background keep-warm loop: refresh every account before it expires even
605
+ // while idle / not active. This is the core fix — the failover target is kept
606
+ // fresh continuously so it's never expired when we switch to it.
607
+ //
608
+ // unref() is CRITICAL: an active timer handle keeps the Node event loop alive
609
+ // and would prevent short-lived CLI commands (e.g. `pi update`) from exiting.
610
+ // We also do NOT fire an immediate refresh here — interactive sessions warm
611
+ // via session_start; firing at module load would add network I/O to the CLI
612
+ // path. The first tick lands one interval later, only in long-lived sessions.
613
+ poolBgTimer = setInterval(() => {
614
+ void poolRefreshAll();
615
+ }, POOL_BG_REFRESH_INTERVAL_MS);
616
+ if (typeof poolBgTimer.unref === "function") poolBgTimer.unref();
617
+
556
618
  // PRIMARY rate/usage-cap detector: provider errors surface on the assistant
557
619
  // message (stopReason "error"), not via after_provider_response (the SDK
558
620
  // throws on 429/529 before pi-ai's onResponse callback runs).
@@ -577,6 +639,23 @@ function setupPool(pi: ExtensionAPI): void {
577
639
  )
578
640
  return undefined;
579
641
  const prev = poolAccounts[poolActive]?.label;
642
+
643
+ // 401 / expired-credential path: the server rejected the ACTIVE token.
644
+ // Force-refresh it in place (don't switch accounts) so the resend works.
645
+ if (
646
+ POOL_AUTH_RE.test(msg.errorMessage) &&
647
+ !POOL_LIMIT_RE.test(msg.errorMessage)
648
+ ) {
649
+ const ok = await poolForceRefresh(poolActive);
650
+ ctx.ui.notify(
651
+ ok
652
+ ? `Claude account "${prev}" had an expired token — refreshed it. Resend your message to continue.`
653
+ : `Claude account "${prev}" token is invalid and refresh FAILED — its refresh token was likely revoked. Run /login anthropic then /claude-pool-add ${prev}.`,
654
+ ok ? "info" : "warning",
655
+ );
656
+ return undefined;
657
+ }
658
+
580
659
  if (!poolHandleErrorMessage(msg.errorMessage)) return undefined;
581
660
  const next = poolAccounts[poolActive]?.label;
582
661
  if (next !== prev) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgltyq/pi-provider-claude",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Claude (Anthropic OAuth/subscription) compatibility layer for pi-coding-agent — keeps ALL extension tools usable under the Claude subscription by mapping unknown flat tool names to mcp__pi__<name> on the wire instead of dropping them, plus optional multi-account failover. Self-contained fork of the tool half of @benvargas/pi-claude-code-use.",
5
5
  "author": "Leechael",
6
6
  "license": "MIT",