@hienlh/ppm 0.8.20 → 0.8.22

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.22] - 2026-03-24
4
+
5
+ ### Fixed
6
+ - **OAuth pre-flight token refresh**: Check token freshness before each SDK query and refresh proactively if expired or expiring within 60s
7
+ - **Auto-refresh on startup**: Run token refresh check immediately on server start instead of waiting 5 minutes for first interval
8
+ - **OAuth auto-refresh on auth failure**: Automatically refresh expired OAuth token and retry when SDK returns `authentication_failed`, instead of just showing error to user
9
+
10
+ ## [0.8.21] - 2026-03-24
11
+
12
+ ### Fixed
13
+ - **OAuth auto-refresh on auth failure**: Automatically refresh expired OAuth token and retry when SDK returns `authentication_failed`, instead of just showing error to user
14
+
3
15
  ## [0.8.20] - 2026-03-24
4
16
 
5
17
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.8.20",
3
+ "version": "0.8.22",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
@@ -46,11 +46,14 @@ if (content.includes("waiting for drain")) {
46
46
  const arg = drainMatch[1];
47
47
  const logger = drainMatch[2];
48
48
 
49
- // Replace backpressure line: await drain instead of just logging
49
+ // Replace backpressure line:
50
+ // - Non-Windows: await drain event (pipe buffers are large, drain fires reliably)
51
+ // - Windows: skip drain — Bun+Windows pipe drain event is unreliable (may never fire
52
+ // in PowerShell). OS still buffers data; subprocess reads when ready.
50
53
  const newLine =
51
54
  `if(!this.processStdin.write(${arg})){` +
52
- `${logger}("[ProcessTransport] Write buffer full, waiting for drain");` +
53
- `await new Promise(_dr=>this.processStdin.once("drain",_dr))}`;
55
+ `${logger}("[ProcessTransport] Write buffer full, "+(process.platform==="win32"?"skipping drain (Windows)":"waiting for drain"));` +
56
+ `if(process.platform!=="win32")await new Promise(_dr=>this.processStdin.once("drain",_dr))}`;
54
57
 
55
58
  content = content.replace(oldLine, newLine);
56
59
 
@@ -162,6 +165,9 @@ if (content.includes("__ppm_manual_readline__")) {
162
165
  `_buf="";_done=true;_notify()` +
163
166
  `});` +
164
167
  `this.processStdout.on("error",(e)=>{_err=e;_done=true;_notify()});` +
168
+ // Bun on Windows may not auto-switch to flowing mode when "data" listener is added.
169
+ // Explicit resume() ensures data events fire.
170
+ `this.processStdout.resume();` +
165
171
  `try{` +
166
172
  `while(true){` +
167
173
  `while(_lines.length>0){` +
@@ -545,7 +545,7 @@ export class ClaudeAgentSdkProvider implements AIProvider {
545
545
  // Account-based auth injection (multi-account mode)
546
546
  // Fallback to existing env (ANTHROPIC_API_KEY) when no accounts configured.
547
547
  const accountsEnabled = accountSelector.isEnabled();
548
- const account = accountsEnabled ? accountSelector.next() : null;
548
+ let account = accountsEnabled ? accountSelector.next() : null;
549
549
  if (accountsEnabled && !account) {
550
550
  // All accounts in DB but none usable
551
551
  const reason = accountSelector.lastFailReason;
@@ -557,7 +557,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
557
557
  yield { type: "done" as const, sessionId, resultSubtype: "error_auth" };
558
558
  return;
559
559
  }
560
+ // Pre-flight: ensure OAuth token is fresh before sending to SDK
560
561
  if (account) {
562
+ const fresh = await accountService.ensureFreshToken(account.id);
563
+ if (fresh) {
564
+ account = fresh;
565
+ }
561
566
  console.log(`[sdk] Using account ${account.id} (${account.email ?? "no-email"})`);
562
567
  yield { type: "account_info" as const, accountId: account.id, accountLabel: account.label ?? account.email ?? "Unknown" };
563
568
  }
@@ -641,8 +646,10 @@ export class ClaudeAgentSdkProvider implements AIProvider {
641
646
 
642
647
  // Retry logic: if SDK returns error_during_execution with 0 turns on first event,
643
648
  // it's a transient subprocess failure — retry once before surfacing the error.
649
+ // Also handles authentication_failed by refreshing OAuth token and retrying.
644
650
  const MAX_RETRIES = 1;
645
651
  let retryCount = 0;
652
+ let authRetried = false;
646
653
 
647
654
  retryLoop: while (true) {
648
655
  let sdkEventCount = 0;
@@ -816,6 +823,52 @@ export class ClaudeAgentSdkProvider implements AIProvider {
816
823
  // Dump full SDK message for debugging
817
824
  console.error(`[sdk] session=${sessionId} cwd=${effectiveCwd} assistant error: ${assistantError} (isFirst=${isFirstMessage} retry=${retryCount})`);
818
825
  console.error(`[sdk] assistant message dump: ${JSON.stringify(msg).slice(0, 2000)}`);
826
+
827
+ // OAuth token expired — refresh and retry once before showing error
828
+ if (assistantError === "authentication_failed" && account && !authRetried) {
829
+ authRetried = true;
830
+ try {
831
+ await accountService.refreshAccessToken(account.id);
832
+ console.log(`[sdk] session=${sessionId} OAuth token refreshed for ${account.id} — retrying`);
833
+ // Re-build env with refreshed token
834
+ const refreshedAccount = accountService.getWithTokens(account.id);
835
+ if (refreshedAccount) {
836
+ const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
837
+ const q = query({
838
+ prompt: message,
839
+ options: {
840
+ sessionId: undefined,
841
+ resume: undefined,
842
+ cwd: effectiveCwd,
843
+ systemPrompt: systemPromptOpt,
844
+ settingSources: ["user", "project"],
845
+ env: retryEnv,
846
+ settings: { permissions: { allow: [], deny: [] } },
847
+ allowedTools,
848
+ permissionMode,
849
+ allowDangerouslySkipPermissions: isBypass,
850
+ ...(permissionHooks && { hooks: permissionHooks }),
851
+ ...(providerConfig.model && { model: providerConfig.model }),
852
+ ...(providerConfig.effort && { effort: providerConfig.effort }),
853
+ maxTurns: providerConfig.max_turns ?? 100,
854
+ ...(providerConfig.max_budget_usd && { maxBudgetUsd: providerConfig.max_budget_usd }),
855
+ ...(providerConfig.thinking_budget_tokens != null && {
856
+ thinkingBudgetTokens: providerConfig.thinking_budget_tokens,
857
+ }),
858
+ canUseTool,
859
+ includePartialMessages: true,
860
+ } as any,
861
+ });
862
+ this.activeQueries.set(sessionId, q);
863
+ eventSource = q;
864
+ continue retryLoop;
865
+ }
866
+ } catch (refreshErr) {
867
+ console.error(`[sdk] session=${sessionId} OAuth refresh failed:`, refreshErr);
868
+ accountSelector.onAuthError(account.id);
869
+ }
870
+ }
871
+
819
872
  const errorHints: Record<string, string> = {
820
873
  authentication_failed: "API authentication failed. Check your account credentials in Settings → Accounts.",
821
874
  billing_error: "Billing error on this account. Check your subscription status.",
@@ -116,6 +116,29 @@ class AccountService {
116
116
  }
117
117
  }
118
118
 
119
+ /**
120
+ * Ensure the access token for an OAuth account is still fresh.
121
+ * If it's expired or about to expire (within 60s), refresh it proactively.
122
+ * Returns the refreshed account with fresh tokens, or null if refresh failed.
123
+ */
124
+ async ensureFreshToken(id: string): Promise<AccountWithTokens | null> {
125
+ const acc = this.getWithTokens(id);
126
+ if (!acc) return null;
127
+ // Only OAuth tokens need refresh
128
+ if (!acc.accessToken.startsWith("sk-ant-oat")) return acc;
129
+ if (!acc.expiresAt) return acc;
130
+ const nowS = Math.floor(Date.now() / 1000);
131
+ if (acc.expiresAt - nowS > 60) return acc; // still fresh
132
+ try {
133
+ console.log(`[accounts] Pre-flight refresh for ${acc.email ?? id} (expires in ${acc.expiresAt - nowS}s)`);
134
+ await this.refreshAccessToken(id);
135
+ return this.getWithTokens(id);
136
+ } catch (e) {
137
+ console.error(`[accounts] Pre-flight refresh failed for ${id}:`, e);
138
+ return null;
139
+ }
140
+ }
141
+
119
142
  /** Find existing account by email or profile UUID */
120
143
  private findDuplicate(email?: string | null, profileData?: OAuthProfileData | null): Account | null {
121
144
  if (!email && !profileData?.account?.uuid) return null;
@@ -586,7 +609,7 @@ class AccountService {
586
609
  const CHECK_INTERVAL_MS = 5 * 60_000;
587
610
  const REFRESH_BUFFER_S = 5 * 60;
588
611
 
589
- this.refreshTimer = setInterval(async () => {
612
+ const refreshExpiring = async () => {
590
613
  const accounts = this.list();
591
614
  const nowS = Math.floor(Date.now() / 1000);
592
615
  for (const acc of accounts) {
@@ -600,7 +623,11 @@ class AccountService {
600
623
  console.error(`[accounts] Auto-refresh failed for ${acc.id}:`, e);
601
624
  }
602
625
  }
603
- }, CHECK_INTERVAL_MS);
626
+ };
627
+
628
+ // Run immediately on startup, then every 5 minutes
629
+ refreshExpiring().catch(() => {});
630
+ this.refreshTimer = setInterval(refreshExpiring, CHECK_INTERVAL_MS);
604
631
 
605
632
  if (typeof this.refreshTimer === "object" && this.refreshTimer !== null && "unref" in this.refreshTimer) {
606
633
  (this.refreshTimer as NodeJS.Timeout).unref();