@hienlh/ppm 0.12.8 → 0.12.10

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.12.10] - 2026-04-21
4
+
5
+ ### Fixed
6
+ - **Auth retry stuck across turns**: `authRetried` was a per-session boolean, so the second 401 in any subsequent turn skipped token refresh and the stream hung. Replaced with per-turn counter (`authRetryCount`, max 2) that resets on each successful turn, so every turn gets a fresh refresh budget
7
+ - **Multi-attempt auth recovery**: Centralized auth-error recovery in `recoverFromAuthError` — attempt 1 refreshes the current account's OAuth token, attempt 2 switches to a different active account, only then surfaces an error. All three 401 detection paths (`api_retry`, assistant text, result) share this logic
8
+ - **api_retry 401 stall**: When no recovery path remains, break immediately instead of falling through to SDK's internal 10x retry (which wasted ~5 minutes before surfacing the error)
9
+
10
+ ## [0.12.9] - 2026-04-21
11
+
12
+ ### Fixed
13
+ - **Synthetic auth error leaking into history**: Filter SDK-persisted error messages (`isApiErrorMessage: true`, `error` field, or `model: "<synthetic>"` with auth/rate-limit text) when loading session history — raw "Failed to authenticate. API Error: 401 …" no longer shows up as an assistant bubble after reload
14
+
3
15
  ## [0.12.8] - 2026-04-21
4
16
 
5
17
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.12.8",
3
+ "version": "0.12.10",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
File without changes
@@ -141,6 +141,57 @@ export class ClaudeAgentSdkProvider implements AIProvider {
141
141
  * Auth env vars are ALWAYS explicitly set so the SDK subprocess never falls back
142
142
  * to reading the project's .env file (which may contain unrelated API keys).
143
143
  */
144
+ /**
145
+ * Recover from a 401/auth error. Strategy:
146
+ * Attempt 1 (authRetryCount === 0): refresh current account's OAuth token.
147
+ * Attempt 2+ (authRetryCount >= 1): switch to a different active account.
148
+ * Yields account_retry events so the FE can update streaming state.
149
+ * Returns the new account + incremented retry count, or null if no recovery path remains.
150
+ * Caller is responsible for rebuilding the SDK query with the returned account.
151
+ */
152
+ private async *recoverFromAuthError(opts: {
153
+ sessionId: string;
154
+ account: AccountWithTokens;
155
+ authRetryCount: number;
156
+ maxRetries: number;
157
+ context: string;
158
+ }): AsyncGenerator<ChatEvent, { account: AccountWithTokens; newRetryCount: number } | null, void> {
159
+ const { sessionId, account, authRetryCount, maxRetries, context } = opts;
160
+
161
+ // Attempt 1: refresh the current account's token
162
+ if (authRetryCount === 0) {
163
+ try {
164
+ await accountService.refreshAccessToken(account.id, false, true);
165
+ const refreshed = accountService.getWithTokens(account.id);
166
+ if (refreshed) {
167
+ const label = refreshed.label ?? refreshed.email ?? "Unknown";
168
+ console.log(`[sdk] session=${sessionId} (${context}) OAuth token refreshed for ${account.id} (${label}) — retrying`);
169
+ yield { type: "account_retry" as const, reason: "Token refreshed", accountId: refreshed.id, accountLabel: label };
170
+ return { account: refreshed, newRetryCount: 1 };
171
+ }
172
+ } catch (err) {
173
+ console.error(`[sdk] session=${sessionId} (${context}) OAuth refresh failed:`, err);
174
+ }
175
+ // Refresh failed — fall through to account switch
176
+ }
177
+
178
+ // Attempt 2+: switch to a different active account
179
+ if (authRetryCount < maxRetries) {
180
+ accountSelector.onAuthError(account.id);
181
+ const nextAcc = accountSelector.next();
182
+ if (nextAcc && nextAcc.id !== account.id) {
183
+ const label = nextAcc.label ?? nextAcc.email ?? "Unknown";
184
+ console.log(`[sdk] session=${sessionId} (${context}) switching to account ${nextAcc.id} (${label}) after auth failure`);
185
+ yield { type: "account_retry" as const, reason: "Switching account", accountId: nextAcc.id, accountLabel: label };
186
+ return { account: nextAcc, newRetryCount: authRetryCount + 1 };
187
+ }
188
+ console.warn(`[sdk] session=${sessionId} (${context}) no alternate account available for switch`);
189
+ } else {
190
+ console.warn(`[sdk] session=${sessionId} (${context}) auth retry budget exhausted (${authRetryCount}/${maxRetries})`);
191
+ }
192
+ return null;
193
+ }
194
+
144
195
  private buildQueryEnv(
145
196
  _projectPath: string | undefined,
146
197
  account: { id: string; accessToken: string } | null,
@@ -840,9 +891,12 @@ export class ClaudeAgentSdkProvider implements AIProvider {
840
891
  const MAX_RETRIES = 1;
841
892
  const MAX_RATE_LIMIT_RETRIES = 3;
842
893
  const RATE_LIMIT_BACKOFF_MS = [15_000, 30_000, 60_000]; // 15s, 30s, 60s
894
+ // Allow 2 refresh attempts per turn (token can rotate mid-conversation).
895
+ // Counter resets on successful turn (see result handler) so next turn gets a fresh budget.
896
+ const MAX_AUTH_RETRIES = 2;
843
897
  let retryCount = 0;
844
898
  let rateLimitRetryCount = 0;
845
- let authRetried = false;
899
+ let authRetryCount = 0;
846
900
  let hadAnyEvents = false;
847
901
  retryLoop: while (true) {
848
902
  // Reset streaming state on retry — clears stale content from failed attempts
@@ -919,59 +973,36 @@ export class ClaudeAgentSdkProvider implements AIProvider {
919
973
  // Intercept SDK's internal api_retry with 401 — the SDK will retry up to 10 times
920
974
  // with exponential backoff using the same expired token, wasting 2+ minutes.
921
975
  // Instead, refresh the OAuth token immediately and restart the query.
922
- if (subtype === "api_retry" && (msg as any).error_status === 401 && account && !authRetried) {
923
- authRetried = true;
924
- try {
925
- // force=true: token got 401, so it's invalid regardless of expiresAt
926
- await accountService.refreshAccessToken(account.id, false, true);
927
- const refreshedAccount = accountService.getWithTokens(account.id);
928
- if (refreshedAccount) {
929
- const label = refreshedAccount.label ?? refreshedAccount.email ?? "Unknown";
930
- console.log(`[sdk] session=${sessionId} intercepted api_retry 401 — refreshing token for ${account.id} (${label})`);
931
- yield { type: "account_retry" as const, reason: "Token refreshed", accountId: refreshedAccount.id, accountLabel: label };
932
- const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
933
- closeCurrentStream();
934
- const { generator: earlyAuthGen, controller: earlyAuthCtrl } = createMessageChannel();
935
- const hasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
936
- if (!hasHistory) earlyAuthCtrl.push(firstMsg);
937
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? sessionId : undefined, env: retryEnv };
938
- const rq = query({
939
- prompt: earlyAuthGen,
940
- options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
941
- });
942
- this.streamingSessions.set(sessionId, { meta, query: rq, controller: earlyAuthCtrl });
943
- this.activeQueries.set(sessionId, rq);
944
- eventSource = rq;
945
- continue retryLoop;
946
- }
947
- } catch (refreshErr) {
948
- console.error(`[sdk] session=${sessionId} early OAuth refresh failed:`, refreshErr);
949
- accountSelector.onAuthError(account.id);
950
- // Refresh failed (e.g. temporary account with no refresh token).
951
- // Abort the current query immediately and try switching to a different account.
952
- const nextAcc = accountSelector.next();
953
- if (nextAcc && nextAcc.id !== account.id) {
954
- account = nextAcc;
955
- const label = nextAcc.label ?? nextAcc.email ?? "Unknown";
956
- console.log(`[sdk] session=${sessionId} refresh failed — switching to ${nextAcc.id} (${label})`);
957
- yield { type: "account_retry" as const, reason: "Switching account", accountId: nextAcc.id, accountLabel: label };
958
- const switchEnv = this.buildQueryEnv(meta.projectPath, nextAcc);
959
- closeCurrentStream();
960
- const { generator: switchGen, controller: switchCtrl } = createMessageChannel();
961
- const hasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
962
- if (!hasHistory) switchCtrl.push(firstMsg);
963
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? sessionId : undefined, env: switchEnv };
964
- const rq = query({
965
- prompt: switchGen,
966
- options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
967
- });
968
- this.streamingSessions.set(sessionId, { meta, query: rq, controller: switchCtrl });
969
- this.activeQueries.set(sessionId, rq);
970
- eventSource = rq;
971
- continue retryLoop;
972
- }
973
- // No other account available — let SDK continue and eventually emit error
976
+ if (subtype === "api_retry" && (msg as any).error_status === 401 && account) {
977
+ const recovered = yield* this.recoverFromAuthError({
978
+ sessionId,
979
+ account,
980
+ authRetryCount,
981
+ maxRetries: MAX_AUTH_RETRIES,
982
+ context: "api_retry",
983
+ });
984
+ if (recovered) {
985
+ authRetryCount = recovered.newRetryCount;
986
+ account = recovered.account;
987
+ const retryEnv = this.buildQueryEnv(meta.projectPath, account);
988
+ closeCurrentStream();
989
+ const { generator: earlyAuthGen, controller: earlyAuthCtrl } = createMessageChannel();
990
+ const hasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
991
+ if (!hasHistory) earlyAuthCtrl.push(firstMsg);
992
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? sessionId : undefined, env: retryEnv };
993
+ const rq = query({
994
+ prompt: earlyAuthGen,
995
+ options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
996
+ });
997
+ this.streamingSessions.set(sessionId, { meta, query: rq, controller: earlyAuthCtrl });
998
+ this.activeQueries.set(sessionId, rq);
999
+ eventSource = rq;
1000
+ continue retryLoop;
974
1001
  }
1002
+ // No recovery possible — break immediately to avoid SDK internal 10x retry hang
1003
+ console.warn(`[sdk] session=${sessionId} api_retry 401 with no recovery — tearing down streaming session`);
1004
+ yield { type: "error", message: "API authentication failed. Check your account credentials in Settings → Accounts." };
1005
+ break;
975
1006
  }
976
1007
 
977
1008
  // Yield system events so streaming loop can transition phases
@@ -1097,47 +1128,35 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1097
1128
  console.error(`[sdk] session=${sessionId} cwd=${effectiveCwd} assistant error: ${assistantError} (isFirst=${isFirstMessage} retry=${retryCount})`);
1098
1129
  console.error(`[sdk] assistant message dump: ${JSON.stringify(msg).slice(0, 2000)}`);
1099
1130
 
1100
- // OAuth token expired — refresh and retry once before showing error
1101
- if (assistantError === "authentication_failed" && account && !authRetried) {
1102
- authRetried = true;
1103
- try {
1104
- // force=true: token got 401, so it's invalid regardless of expiresAt
1105
- await accountService.refreshAccessToken(account.id, false, true);
1106
- const refreshedAccount = accountService.getWithTokens(account.id);
1107
- if (refreshedAccount) {
1108
- const label = refreshedAccount.label ?? refreshedAccount.email ?? "Unknown";
1109
- console.log(`[sdk] session=${sessionId} OAuth token refreshed for ${account.id} (${label}) — retrying`);
1110
- yield { type: "account_retry" as const, reason: "Token refreshed", accountId: refreshedAccount.id, accountLabel: label };
1111
- const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
1112
- // Close failed query and old channel, create new channel + query with refreshed token.
1113
- closeCurrentStream();
1114
- const { generator: authRetryGen, controller: authRetryCtrl } = createMessageChannel();
1115
- const hasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
1116
- if (!hasHistory) authRetryCtrl.push(firstMsg);
1117
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? sessionId : undefined, env: retryEnv };
1118
- const rq = query({
1119
- prompt: authRetryGen,
1120
- options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
1121
- });
1122
- this.streamingSessions.set(sessionId, { meta, query: rq, controller: authRetryCtrl });
1123
- this.activeQueries.set(sessionId, rq);
1124
- eventSource = rq;
1125
- continue retryLoop;
1126
- }
1127
- } catch (refreshErr) {
1128
- console.error(`[sdk] session=${sessionId} OAuth refresh failed:`, refreshErr);
1129
- accountSelector.onAuthError(account.id);
1131
+ // OAuth token expired — refresh (and/or switch account) and retry
1132
+ if (assistantError === "authentication_failed" && account) {
1133
+ const recovered = yield* this.recoverFromAuthError({
1134
+ sessionId,
1135
+ account,
1136
+ authRetryCount,
1137
+ maxRetries: MAX_AUTH_RETRIES,
1138
+ context: "assistant",
1139
+ });
1140
+ if (recovered) {
1141
+ authRetryCount = recovered.newRetryCount;
1142
+ account = recovered.account;
1143
+ const retryEnv = this.buildQueryEnv(meta.projectPath, account);
1144
+ closeCurrentStream();
1145
+ const { generator: authRetryGen, controller: authRetryCtrl } = createMessageChannel();
1146
+ const hasHistory = (this.messageCount.get(sessionId) ?? 0) > 0;
1147
+ if (!hasHistory) authRetryCtrl.push(firstMsg);
1148
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: hasHistory ? sessionId : undefined, env: retryEnv };
1149
+ const rq = query({
1150
+ prompt: authRetryGen,
1151
+ options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
1152
+ });
1153
+ this.streamingSessions.set(sessionId, { meta, query: rq, controller: authRetryCtrl });
1154
+ this.activeQueries.set(sessionId, rq);
1155
+ eventSource = rq;
1156
+ continue retryLoop;
1130
1157
  }
1131
- }
1132
-
1133
- // Auth failed permanently after retry — cooldown account and break loop.
1134
- // SDK doesn't send a result event after auth errors in streaming mode,
1135
- // so the streaming session would stay alive with broken credentials forever.
1136
- // Breaking here lets the finally block tear down the session, so the next
1137
- // user message creates a fresh session with a different account.
1138
- if (assistantError === "authentication_failed" && account && authRetried) {
1139
- accountSelector.onAuthError(account.id);
1140
- console.warn(`[sdk] session=${sessionId} auth permanently failed — tearing down streaming session`);
1158
+ // All recovery exhausted — tear down streaming session
1159
+ console.warn(`[sdk] session=${sessionId} auth permanently failed after ${authRetryCount} attempts — tearing down streaming session`);
1141
1160
  yield { type: "error", message: "API authentication failed. Check your account credentials in Settings → Accounts." };
1142
1161
  break;
1143
1162
  }
@@ -1275,38 +1294,33 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1275
1294
  yield { type: "error", message: `Rate limited. Retried ${MAX_RATE_LIMIT_RETRIES} times without success.` };
1276
1295
  continue;
1277
1296
  } else if (errCode === 401) {
1278
- // Refresh token and retry — resume existing SDK session to preserve context
1279
- if (!authRetried) {
1280
- authRetried = true;
1281
- try {
1282
- // force=true: token got 401, so it's invalid regardless of expiresAt
1283
- await accountService.refreshAccessToken(account.id, false, true);
1284
- const refreshedAccount = accountService.getWithTokens(account.id);
1285
- if (refreshedAccount) {
1286
- const label = refreshedAccount.label ?? refreshedAccount.email ?? "Unknown";
1287
- console.log(`[sdk] 401 in result on account ${account.id} (${label}) — token refreshed, retrying`);
1288
- yield { type: "account_retry" as const, reason: "Token refreshed", accountId: refreshedAccount.id, accountLabel: label };
1289
- closeCurrentStream();
1290
- const retryEnv = this.buildQueryEnv(meta.projectPath, refreshedAccount);
1291
- const { generator: authRetryGen2, controller: authRetryCtrl2 } = createMessageChannel();
1292
- const authHasHistory2 = (this.messageCount.get(sessionId) ?? 0) > 0;
1293
- if (!authHasHistory2) authRetryCtrl2.push(firstMsg);
1294
- const retryOpts = { ...queryOptions, sessionId: undefined, resume: authHasHistory2 ? sessionId : undefined, env: retryEnv };
1295
- const rq = query({
1296
- prompt: authRetryGen2,
1297
- options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
1298
- });
1299
- this.streamingSessions.set(sessionId, { meta, query: rq, controller: authRetryCtrl2 });
1300
- this.activeQueries.set(sessionId, rq);
1301
- eventSource = rq;
1302
- continue retryLoop;
1303
- }
1304
- } catch {
1305
- accountSelector.onAuthError(account.id);
1306
- }
1307
- } else {
1308
- accountSelector.onAuthError(account.id);
1297
+ // Refresh (or switch account) and retry — resume existing SDK session to preserve context
1298
+ const recovered = yield* this.recoverFromAuthError({
1299
+ sessionId,
1300
+ account,
1301
+ authRetryCount,
1302
+ maxRetries: MAX_AUTH_RETRIES,
1303
+ context: "result",
1304
+ });
1305
+ if (recovered) {
1306
+ authRetryCount = recovered.newRetryCount;
1307
+ account = recovered.account;
1308
+ closeCurrentStream();
1309
+ const retryEnv = this.buildQueryEnv(meta.projectPath, account);
1310
+ const { generator: authRetryGen2, controller: authRetryCtrl2 } = createMessageChannel();
1311
+ const authHasHistory2 = (this.messageCount.get(sessionId) ?? 0) > 0;
1312
+ if (!authHasHistory2) authRetryCtrl2.push(firstMsg);
1313
+ const retryOpts = { ...queryOptions, sessionId: undefined, resume: authHasHistory2 ? sessionId : undefined, env: retryEnv };
1314
+ const rq = query({
1315
+ prompt: authRetryGen2,
1316
+ options: { ...retryOpts, ...(permissionHooks && { hooks: permissionHooks }), canUseTool } as any,
1317
+ });
1318
+ this.streamingSessions.set(sessionId, { meta, query: rq, controller: authRetryCtrl2 });
1319
+ this.activeQueries.set(sessionId, rq);
1320
+ eventSource = rq;
1321
+ continue retryLoop;
1309
1322
  }
1323
+ // All recovery exhausted — fall through to normal result error surfacing
1310
1324
  } else {
1311
1325
  // Only mark success when the result is actually successful,
1312
1326
  // not for unrecognized error subtypes (e.g. quota exhaustion)
@@ -1443,6 +1457,9 @@ export class ClaudeAgentSdkProvider implements AIProvider {
1443
1457
  resultContextWindowPct = undefined;
1444
1458
  lastAssistantUuid = undefined;
1445
1459
  sdkEventCount = 0;
1460
+ // Reset auth retry budget on successful turn — each new turn gets a fresh
1461
+ // budget so OAuth tokens rotated mid-conversation can still trigger refresh
1462
+ if (!subtype || subtype === "success") authRetryCount = 0;
1446
1463
  continue; // Wait for next turn from generator
1447
1464
  }
1448
1465
  }
@@ -1576,6 +1593,29 @@ function parseSessionMessage(msg: { uuid: string; type: string; message: unknown
1576
1593
  const role = msg.type as "user" | "assistant";
1577
1594
  const parentId = (msg as any).parent_tool_use_id as string | undefined;
1578
1595
 
1596
+ // Filter synthetic SDK-generated error messages (auth failures, rate limits, etc.).
1597
+ // Structure: { isApiErrorMessage: true, error: "authentication_failed"|"rate_limit"|...,
1598
+ // message: { model: "<synthetic>", content: [{text: "Failed to authenticate..."}] } }
1599
+ // Our retry loop handles these; the raw text must not render in chat history.
1600
+ const isSdkErrorMessage =
1601
+ (msg as any).isApiErrorMessage === true ||
1602
+ typeof (msg as any).error === "string" ||
1603
+ (message && (message as any).model === "<synthetic>" &&
1604
+ Array.isArray(message.content) &&
1605
+ (message.content as Array<Record<string, unknown>>).some(
1606
+ (b) => b.type === "text" && typeof b.text === "string" &&
1607
+ /Failed to authenticate|API Error: 40[13]|hit your limit|rate.?limit/i.test(b.text as string),
1608
+ ));
1609
+ if (isSdkErrorMessage) {
1610
+ return {
1611
+ id: msg.uuid,
1612
+ role,
1613
+ content: "",
1614
+ timestamp: new Date().toISOString(),
1615
+ sdkUuid: msg.uuid,
1616
+ };
1617
+ }
1618
+
1579
1619
  // Parse content blocks for both user and assistant messages
1580
1620
  const events: ChatEvent[] = [];
1581
1621
  let textContent = "";