@hienlh/ppm 0.11.1 → 0.11.2

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 CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.11.2] - 2026-04-19
4
+
5
+ ### Fixed
6
+ - **Token refresh on 401**: Force actual OAuth refresh when API returns 401, even if token appears fresh by expiry timestamp — fixes intermittent "Token refreshed" message that didn't actually refresh
7
+
3
8
  ## [0.11.1] - 2026-04-19
4
9
 
5
10
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -916,9 +916,8 @@ export class ClaudeAgentSdkProvider implements AIProvider {
916
916
  if (subtype === "api_retry" && (msg as any).error_status === 401 && account && !authRetried) {
917
917
  authRetried = true;
918
918
  try {
919
- // refreshAccessToken has mutex + skip-if-fresh: if another session already
920
- // refreshed, it returns immediately without calling OAuth again.
921
- await accountService.refreshAccessToken(account.id, false);
919
+ // force=true: token got 401, so it's invalid regardless of expiresAt
920
+ await accountService.refreshAccessToken(account.id, false, true);
922
921
  const refreshedAccount = accountService.getWithTokens(account.id);
923
922
  if (refreshedAccount) {
924
923
  const label = refreshedAccount.label ?? refreshedAccount.email ?? "Unknown";
@@ -1093,9 +1092,8 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1093
1092
  if (assistantError === "authentication_failed" && account && !authRetried) {
1094
1093
  authRetried = true;
1095
1094
  try {
1096
- // refreshAccessToken has mutex + skip-if-fresh: if another session already
1097
- // refreshed, it returns immediately without calling OAuth again.
1098
- await accountService.refreshAccessToken(account.id, false);
1095
+ // force=true: token got 401, so it's invalid regardless of expiresAt
1096
+ await accountService.refreshAccessToken(account.id, false, true);
1099
1097
  const refreshedAccount = accountService.getWithTokens(account.id);
1100
1098
  if (refreshedAccount) {
1101
1099
  const label = refreshedAccount.label ?? refreshedAccount.email ?? "Unknown";
@@ -1272,9 +1270,8 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1272
1270
  if (!authRetried) {
1273
1271
  authRetried = true;
1274
1272
  try {
1275
- // refreshAccessToken has mutex + skip-if-fresh: if another session already
1276
- // refreshed, it returns immediately without calling OAuth again.
1277
- await accountService.refreshAccessToken(account.id, false);
1273
+ // force=true: token got 401, so it's invalid regardless of expiresAt
1274
+ await accountService.refreshAccessToken(account.id, false, true);
1278
1275
  const refreshedAccount = accountService.getWithTokens(account.id);
1279
1276
  if (refreshedAccount) {
1280
1277
  const label = refreshedAccount.label ?? refreshedAccount.email ?? "Unknown";
@@ -527,8 +527,10 @@ class AccountService {
527
527
  * Also skips the OAuth call if the DB token was already refreshed by another session.
528
528
  * @param disableOnFail - if true, disable the account when refresh fails (default: true).
529
529
  * Background/startup refresh should pass false to avoid disabling accounts prematurely.
530
+ * @param force - if true, bypass the skip-if-fresh check (use after 401 errors where
531
+ * the token is demonstrably invalid despite having a future expiresAt).
530
532
  */
531
- async refreshAccessToken(accountId: string, disableOnFail = true): Promise<void> {
533
+ async refreshAccessToken(accountId: string, disableOnFail = true, force = false): Promise<void> {
532
534
  // Dedup: if a refresh is already in progress for this account, wait for it instead of racing
533
535
  const pending = this.pendingRefreshes.get(accountId);
534
536
  if (pending) {
@@ -536,7 +538,7 @@ class AccountService {
536
538
  return pending;
537
539
  }
538
540
 
539
- const promise = this._doRefreshAccessToken(accountId, disableOnFail);
541
+ const promise = this._doRefreshAccessToken(accountId, disableOnFail, force);
540
542
  this.pendingRefreshes.set(accountId, promise);
541
543
  try {
542
544
  await promise;
@@ -545,16 +547,18 @@ class AccountService {
545
547
  }
546
548
  }
547
549
 
548
- private async _doRefreshAccessToken(accountId: string, disableOnFail: boolean): Promise<void> {
550
+ private async _doRefreshAccessToken(accountId: string, disableOnFail: boolean, force = false): Promise<void> {
549
551
  const account = this.getWithTokens(accountId);
550
552
  if (!account) throw new Error(`Account ${accountId} not found`);
551
553
  // Skip refresh for temporary accounts (no refresh token)
552
554
  if (!account.refreshToken || account.refreshToken === "") {
553
555
  throw new Error(`Account ${accountId} has no refresh token (temporary account)`);
554
556
  }
555
- // Skip if token was already refreshed by another session (still fresh)
557
+ // Skip if token was already refreshed by another session (still fresh).
558
+ // But when force=true (after a 401), always refresh — the token may be
559
+ // revoked server-side despite having a future expiresAt.
556
560
  const nowS = Math.floor(Date.now() / 1000);
557
- if (account.expiresAt && account.expiresAt - nowS > 60) {
561
+ if (!force && account.expiresAt && account.expiresAt - nowS > 60) {
558
562
  console.log(`[accounts] Token for ${account.email ?? accountId} is already fresh (expires in ${account.expiresAt - nowS}s) — skipping OAuth refresh`);
559
563
  return;
560
564
  }