gsd-pi 2.10.2 → 2.10.5

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 (136) hide show
  1. package/README.md +2 -0
  2. package/dist/cli.js +7 -0
  3. package/dist/loader.js +1 -0
  4. package/dist/onboarding.js +104 -59
  5. package/dist/update-cmd.d.ts +1 -0
  6. package/dist/update-cmd.js +40 -0
  7. package/node_modules/@gsd/native/dist/hasher/index.d.ts +32 -0
  8. package/node_modules/@gsd/native/dist/hasher/index.js +37 -0
  9. package/node_modules/@gsd/native/dist/native.d.ts +4 -1
  10. package/node_modules/@gsd/native/dist/native.js +39 -9
  11. package/node_modules/@gsd/native/dist/xxhash/index.d.ts +14 -0
  12. package/node_modules/@gsd/native/dist/xxhash/index.js +17 -0
  13. package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.d.ts +6 -0
  14. package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  15. package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.js +58 -9
  16. package/node_modules/@gsd/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  17. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.d.ts +72 -12
  18. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.d.ts.map +1 -1
  19. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.js +254 -43
  20. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.js.map +1 -1
  21. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.d.ts +2 -0
  22. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.d.ts.map +1 -0
  23. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.js +159 -0
  24. package/node_modules/@gsd/pi-coding-agent/dist/core/auth-storage.test.js.map +1 -0
  25. package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.d.ts +4 -2
  26. package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
  27. package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.js +6 -4
  28. package/node_modules/@gsd/pi-coding-agent/dist/core/model-registry.js.map +1 -1
  29. package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.d.ts +15 -0
  30. package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  31. package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.js +12 -0
  32. package/node_modules/@gsd/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  33. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts +40 -0
  34. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts.map +1 -0
  35. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.js +92 -0
  36. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.js.map +1 -0
  37. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts +2 -0
  38. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts.map +1 -0
  39. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.js +156 -0
  40. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash-interceptor.test.js.map +1 -0
  41. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.d.ts +8 -0
  42. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
  43. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.js +18 -0
  44. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  45. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.d.ts +1 -0
  46. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
  47. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.js +1 -0
  48. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/index.js.map +1 -1
  49. package/node_modules/@gsd/pi-coding-agent/dist/index.d.ts +2 -2
  50. package/node_modules/@gsd/pi-coding-agent/dist/index.d.ts.map +1 -1
  51. package/node_modules/@gsd/pi-coding-agent/dist/index.js +1 -1
  52. package/node_modules/@gsd/pi-coding-agent/dist/index.js.map +1 -1
  53. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  54. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  55. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  56. package/node_modules/@gsd/pi-coding-agent/src/core/agent-session.ts +65 -9
  57. package/node_modules/@gsd/pi-coding-agent/src/core/auth-storage.test.ts +194 -0
  58. package/node_modules/@gsd/pi-coding-agent/src/core/auth-storage.ts +283 -53
  59. package/node_modules/@gsd/pi-coding-agent/src/core/model-registry.ts +6 -4
  60. package/node_modules/@gsd/pi-coding-agent/src/core/settings-manager.ts +29 -0
  61. package/node_modules/@gsd/pi-coding-agent/src/core/tools/bash-interceptor.test.ts +198 -0
  62. package/node_modules/@gsd/pi-coding-agent/src/core/tools/bash-interceptor.ts +115 -0
  63. package/node_modules/@gsd/pi-coding-agent/src/core/tools/bash.ts +29 -0
  64. package/node_modules/@gsd/pi-coding-agent/src/core/tools/index.ts +8 -0
  65. package/node_modules/@gsd/pi-coding-agent/src/index.ts +6 -0
  66. package/node_modules/@gsd/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  67. package/package.json +8 -2
  68. package/packages/native/dist/hasher/index.d.ts +32 -0
  69. package/packages/native/dist/hasher/index.js +37 -0
  70. package/packages/native/dist/native.d.ts +4 -1
  71. package/packages/native/dist/native.js +39 -9
  72. package/packages/native/dist/xxhash/index.d.ts +14 -0
  73. package/packages/native/dist/xxhash/index.js +17 -0
  74. package/packages/native/src/native.ts +39 -9
  75. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +6 -0
  76. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  77. package/packages/pi-coding-agent/dist/core/agent-session.js +58 -9
  78. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  79. package/packages/pi-coding-agent/dist/core/auth-storage.d.ts +72 -12
  80. package/packages/pi-coding-agent/dist/core/auth-storage.d.ts.map +1 -1
  81. package/packages/pi-coding-agent/dist/core/auth-storage.js +254 -43
  82. package/packages/pi-coding-agent/dist/core/auth-storage.js.map +1 -1
  83. package/packages/pi-coding-agent/dist/core/auth-storage.test.d.ts +2 -0
  84. package/packages/pi-coding-agent/dist/core/auth-storage.test.d.ts.map +1 -0
  85. package/packages/pi-coding-agent/dist/core/auth-storage.test.js +159 -0
  86. package/packages/pi-coding-agent/dist/core/auth-storage.test.js.map +1 -0
  87. package/packages/pi-coding-agent/dist/core/model-registry.d.ts +4 -2
  88. package/packages/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
  89. package/packages/pi-coding-agent/dist/core/model-registry.js +6 -4
  90. package/packages/pi-coding-agent/dist/core/model-registry.js.map +1 -1
  91. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +15 -0
  92. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  93. package/packages/pi-coding-agent/dist/core/settings-manager.js +12 -0
  94. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  95. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts +40 -0
  96. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.d.ts.map +1 -0
  97. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.js +92 -0
  98. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.js.map +1 -0
  99. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts +2 -0
  100. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.d.ts.map +1 -0
  101. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.js +156 -0
  102. package/packages/pi-coding-agent/dist/core/tools/bash-interceptor.test.js.map +1 -0
  103. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts +8 -0
  104. package/packages/pi-coding-agent/dist/core/tools/bash.d.ts.map +1 -1
  105. package/packages/pi-coding-agent/dist/core/tools/bash.js +18 -0
  106. package/packages/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  107. package/packages/pi-coding-agent/dist/core/tools/index.d.ts +1 -0
  108. package/packages/pi-coding-agent/dist/core/tools/index.d.ts.map +1 -1
  109. package/packages/pi-coding-agent/dist/core/tools/index.js +1 -0
  110. package/packages/pi-coding-agent/dist/core/tools/index.js.map +1 -1
  111. package/packages/pi-coding-agent/dist/index.d.ts +2 -2
  112. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  113. package/packages/pi-coding-agent/dist/index.js +1 -1
  114. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  115. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  116. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +1 -1
  117. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  118. package/packages/pi-coding-agent/src/core/agent-session.ts +65 -9
  119. package/packages/pi-coding-agent/src/core/auth-storage.test.ts +194 -0
  120. package/packages/pi-coding-agent/src/core/auth-storage.ts +283 -53
  121. package/packages/pi-coding-agent/src/core/model-registry.ts +6 -4
  122. package/packages/pi-coding-agent/src/core/settings-manager.ts +29 -0
  123. package/packages/pi-coding-agent/src/core/tools/bash-interceptor.test.ts +198 -0
  124. package/packages/pi-coding-agent/src/core/tools/bash-interceptor.ts +115 -0
  125. package/packages/pi-coding-agent/src/core/tools/bash.ts +29 -0
  126. package/packages/pi-coding-agent/src/core/tools/index.ts +8 -0
  127. package/packages/pi-coding-agent/src/index.ts +6 -0
  128. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +1 -1
  129. package/src/resources/extensions/async-jobs/async-bash-tool.ts +211 -0
  130. package/src/resources/extensions/async-jobs/await-tool.ts +101 -0
  131. package/src/resources/extensions/async-jobs/cancel-job-tool.ts +34 -0
  132. package/src/resources/extensions/async-jobs/index.ts +133 -0
  133. package/src/resources/extensions/async-jobs/job-manager.ts +250 -0
  134. package/src/resources/extensions/gsd/git-service.ts +13 -3
  135. package/src/resources/extensions/gsd/prompts/system.md +5 -2
  136. package/src/resources/extensions/gsd/tests/git-service.test.ts +36 -0
@@ -2,6 +2,9 @@
2
2
  * Credential storage for API keys and OAuth tokens.
3
3
  * Handles loading, saving, and refreshing credentials from auth.json.
4
4
  *
5
+ * Supports multiple credentials per provider with round-robin selection,
6
+ * session-sticky hashing, and automatic rate-limit fallback.
7
+ *
5
8
  * Uses file locking to prevent race conditions when multiple pi instances
6
9
  * try to refresh tokens simultaneously.
7
10
  */
@@ -30,7 +33,11 @@ export type OAuthCredential = {
30
33
 
31
34
  export type AuthCredential = ApiKeyCredential | OAuthCredential;
32
35
 
33
- export type AuthStorageData = Record<string, AuthCredential>;
36
+ /**
37
+ * On-disk format: each provider maps to a single credential or an array of credentials.
38
+ * Single credentials are normalized to arrays at load time for internal use.
39
+ */
40
+ export type AuthStorageData = Record<string, AuthCredential | AuthCredential[]>;
34
41
 
35
42
  type LockResult<T> = {
36
43
  result: T;
@@ -178,8 +185,49 @@ export class InMemoryAuthStorageBackend implements AuthStorageBackend {
178
185
  }
179
186
  }
180
187
 
188
+ // ============================================================================
189
+ // Backoff durations for different error types (milliseconds)
190
+ // ============================================================================
191
+
192
+ const BACKOFF_RATE_LIMIT_MS = 30_000; // 30s for rate limit / 429
193
+ const BACKOFF_QUOTA_EXHAUSTED_MS = 30 * 60_000; // 30min for quota exhausted
194
+ const BACKOFF_SERVER_ERROR_MS = 20_000; // 20s for 5xx server errors
195
+ const BACKOFF_DEFAULT_MS = 60_000; // 60s fallback
196
+
197
+ export type UsageLimitErrorType = "rate_limit" | "quota_exhausted" | "server_error" | "unknown";
198
+
199
+ /**
200
+ * Get backoff duration for an error type.
201
+ */
202
+ function getBackoffDuration(errorType: UsageLimitErrorType): number {
203
+ switch (errorType) {
204
+ case "rate_limit":
205
+ return BACKOFF_RATE_LIMIT_MS;
206
+ case "quota_exhausted":
207
+ return BACKOFF_QUOTA_EXHAUSTED_MS;
208
+ case "server_error":
209
+ return BACKOFF_SERVER_ERROR_MS;
210
+ default:
211
+ return BACKOFF_DEFAULT_MS;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Simple string hash for session-sticky credential selection.
217
+ * Returns a positive integer.
218
+ */
219
+ function hashString(str: string): number {
220
+ let hash = 0;
221
+ for (let i = 0; i < str.length; i++) {
222
+ const char = str.charCodeAt(i);
223
+ hash = ((hash << 5) - hash + char) | 0;
224
+ }
225
+ return Math.abs(hash);
226
+ }
227
+
181
228
  /**
182
229
  * Credential storage backed by a JSON file.
230
+ * Supports multiple credentials per provider with round-robin rotation and rate-limit fallback.
183
231
  */
184
232
  export class AuthStorage {
185
233
  private data: AuthStorageData = {};
@@ -188,6 +236,18 @@ export class AuthStorage {
188
236
  private loadError: Error | null = null;
189
237
  private errors: Error[] = [];
190
238
 
239
+ /**
240
+ * Round-robin index per provider. Incremented on each call to getApiKey
241
+ * when no sessionId is provided.
242
+ */
243
+ private providerRoundRobinIndex: Map<string, number> = new Map();
244
+
245
+ /**
246
+ * Backoff tracking per provider per credential index.
247
+ * Map<provider, Map<credentialIndex, backoffExpiresAt>>
248
+ */
249
+ private credentialBackoff: Map<string, Map<number, number>> = new Map();
250
+
191
251
  private constructor(private storage: AuthStorageBackend) {
192
252
  this.reload();
193
253
  }
@@ -241,6 +301,17 @@ export class AuthStorage {
241
301
  return JSON.parse(content) as AuthStorageData;
242
302
  }
243
303
 
304
+ /**
305
+ * Normalize a storage entry to an array of credentials.
306
+ * Handles both single credential (backward compat) and array formats.
307
+ */
308
+ getCredentialsForProvider(provider: string): AuthCredential[] {
309
+ const entry = this.data[provider];
310
+ if (!entry) return [];
311
+ if (Array.isArray(entry)) return entry;
312
+ return [entry];
313
+ }
314
+
244
315
  /**
245
316
  * Reload credentials from storage.
246
317
  */
@@ -259,7 +330,7 @@ export class AuthStorage {
259
330
  }
260
331
  }
261
332
 
262
- private persistProviderChange(provider: string, credential: AuthCredential | undefined): void {
333
+ private persistProviderChange(provider: string, credential: AuthCredential | AuthCredential[] | undefined): void {
263
334
  if (this.loadError) {
264
335
  return;
265
336
  }
@@ -281,25 +352,52 @@ export class AuthStorage {
281
352
  }
282
353
 
283
354
  /**
284
- * Get credential for a provider.
355
+ * Get the first credential for a provider (backward-compatible).
285
356
  */
286
357
  get(provider: string): AuthCredential | undefined {
287
- return this.data[provider] ?? undefined;
358
+ const creds = this.getCredentialsForProvider(provider);
359
+ return creds[0] ?? undefined;
288
360
  }
289
361
 
290
362
  /**
291
- * Set credential for a provider.
363
+ * Set credential for a provider. For API key credentials, appends to
364
+ * existing credentials (accumulation on duplicate login). For OAuth,
365
+ * replaces (only one OAuth token per provider makes sense).
292
366
  */
293
367
  set(provider: string, credential: AuthCredential): void {
294
- this.data[provider] = credential;
295
- this.persistProviderChange(provider, credential);
368
+ if (credential.type === "api_key") {
369
+ const existing = this.getCredentialsForProvider(provider);
370
+ // Deduplicate: don't add if same key already exists
371
+ const isDuplicate = existing.some(
372
+ (c) => c.type === "api_key" && c.key === credential.key,
373
+ );
374
+ if (isDuplicate) return;
375
+
376
+ const updated = [...existing, credential];
377
+ this.data[provider] = updated.length === 1 ? updated[0] : updated;
378
+ this.persistProviderChange(provider, updated.length === 1 ? updated[0] : updated);
379
+ } else {
380
+ // OAuth: replace any existing OAuth credential, keep API keys
381
+ const existing = this.getCredentialsForProvider(provider);
382
+ const apiKeys = existing.filter((c) => c.type === "api_key");
383
+ if (apiKeys.length === 0) {
384
+ this.data[provider] = credential;
385
+ this.persistProviderChange(provider, credential);
386
+ } else {
387
+ const updated = [...apiKeys, credential];
388
+ this.data[provider] = updated;
389
+ this.persistProviderChange(provider, updated);
390
+ }
391
+ }
296
392
  }
297
393
 
298
394
  /**
299
- * Remove credential for a provider.
395
+ * Remove all credentials for a provider.
300
396
  */
301
397
  remove(provider: string): void {
302
398
  delete this.data[provider];
399
+ this.providerRoundRobinIndex.delete(provider);
400
+ this.credentialBackoff.delete(provider);
303
401
  this.persistProviderChange(provider, undefined);
304
402
  }
305
403
 
@@ -331,9 +429,19 @@ export class AuthStorage {
331
429
 
332
430
  /**
333
431
  * Get all credentials (for passing to getOAuthApiKey).
432
+ * Returns normalized format where each provider has a single credential
433
+ * (the first one) for backward compatibility with OAuth refresh.
434
+ *
435
+ * NOTE: For providers with multiple API keys, only the first credential is
436
+ * returned. This is intentional — callers use this for OAuth refresh only,
437
+ * which is always single-credential. Do not use for API key enumeration.
334
438
  */
335
- getAll(): AuthStorageData {
336
- return { ...this.data };
439
+ getAll(): Record<string, AuthCredential> {
440
+ const result: Record<string, AuthCredential> = {};
441
+ for (const [provider, entry] of Object.entries(this.data)) {
442
+ result[provider] = Array.isArray(entry) ? entry[0] : entry;
443
+ }
444
+ return result;
337
445
  }
338
446
 
339
447
  drainErrors(): Error[] {
@@ -362,6 +470,108 @@ export class AuthStorage {
362
470
  this.remove(provider);
363
471
  }
364
472
 
473
+ /**
474
+ * Check if a credential index is currently backed off.
475
+ */
476
+ private isCredentialBackedOff(provider: string, index: number): boolean {
477
+ const providerBackoff = this.credentialBackoff.get(provider);
478
+ if (!providerBackoff) return false;
479
+ const expiresAt = providerBackoff.get(index);
480
+ if (expiresAt === undefined) return false;
481
+ if (Date.now() >= expiresAt) {
482
+ providerBackoff.delete(index);
483
+ return false;
484
+ }
485
+ return true;
486
+ }
487
+
488
+ /**
489
+ * Select the best credential index for a provider.
490
+ * - If sessionId is provided, uses session-sticky hashing as the starting point.
491
+ * - Otherwise, uses round-robin as the starting point.
492
+ * - Skips credentials that are currently backed off.
493
+ * - Returns -1 if all credentials are backed off.
494
+ */
495
+ private selectCredentialIndex(provider: string, credentials: AuthCredential[], sessionId?: string): number {
496
+ if (credentials.length === 0) return -1;
497
+ if (credentials.length === 1) {
498
+ return this.isCredentialBackedOff(provider, 0) ? -1 : 0;
499
+ }
500
+
501
+ let startIndex: number;
502
+ if (sessionId) {
503
+ startIndex = hashString(sessionId) % credentials.length;
504
+ } else {
505
+ const current = this.providerRoundRobinIndex.get(provider) ?? 0;
506
+ startIndex = current % credentials.length;
507
+ this.providerRoundRobinIndex.set(provider, current + 1);
508
+ }
509
+
510
+ // Try starting from the preferred index, wrapping around
511
+ for (let offset = 0; offset < credentials.length; offset++) {
512
+ const index = (startIndex + offset) % credentials.length;
513
+ if (!this.isCredentialBackedOff(provider, index)) {
514
+ return index;
515
+ }
516
+ }
517
+
518
+ // All credentials are backed off
519
+ return -1;
520
+ }
521
+
522
+ /**
523
+ * Mark a credential as rate-limited. Finds the credential that was most
524
+ * recently used for this provider+session and backs it off.
525
+ *
526
+ * @returns true if another credential is available (caller should retry),
527
+ * false if all credentials for this provider are backed off.
528
+ */
529
+ markUsageLimitReached(
530
+ provider: string,
531
+ sessionId?: string,
532
+ options?: { errorType?: UsageLimitErrorType },
533
+ ): boolean {
534
+ const credentials = this.getCredentialsForProvider(provider);
535
+ if (credentials.length === 0) return false;
536
+
537
+ const errorType = options?.errorType ?? "rate_limit";
538
+ const backoffMs = getBackoffDuration(errorType);
539
+
540
+ // Determine which credential was just used (same logic as selectCredentialIndex
541
+ // but without incrementing round-robin)
542
+ let usedIndex: number;
543
+ if (credentials.length === 1) {
544
+ usedIndex = 0;
545
+ } else if (sessionId) {
546
+ usedIndex = hashString(sessionId) % credentials.length;
547
+ } else {
548
+ // Round-robin was already incremented in getApiKey, so the last-used
549
+ // index is (current - 1). Note: in a concurrent scenario where another
550
+ // getApiKey call fires between the original request and this backoff call,
551
+ // we may back off the wrong credential index. This is acceptable because:
552
+ // (a) pi runs single-threaded event loop, (b) backing off the wrong key
553
+ // is safe — it self-heals when the backoff expires.
554
+ const current = this.providerRoundRobinIndex.get(provider) ?? 0;
555
+ usedIndex = ((current - 1) % credentials.length + credentials.length) % credentials.length;
556
+ }
557
+
558
+ // Set backoff for this credential
559
+ let providerBackoff = this.credentialBackoff.get(provider);
560
+ if (!providerBackoff) {
561
+ providerBackoff = new Map();
562
+ this.credentialBackoff.set(provider, providerBackoff);
563
+ }
564
+ providerBackoff.set(usedIndex, Date.now() + backoffMs);
565
+
566
+ // Check if any credential is still available
567
+ for (let i = 0; i < credentials.length; i++) {
568
+ if (!this.isCredentialBackedOff(provider, i)) {
569
+ return true;
570
+ }
571
+ }
572
+ return false;
573
+ }
574
+
365
575
  /**
366
576
  * Refresh OAuth token with backend locking to prevent race conditions.
367
577
  * Multiple pi instances may try to refresh simultaneously when tokens expire.
@@ -379,8 +589,10 @@ export class AuthStorage {
379
589
  this.data = currentData;
380
590
  this.loadError = null;
381
591
 
382
- const cred = currentData[providerId];
383
- if (cred?.type !== "oauth") {
592
+ // Find the OAuth credential for this provider
593
+ const creds = this.getCredentialsForProvider(providerId);
594
+ const cred = creds.find((c) => c.type === "oauth");
595
+ if (!cred || cred.type !== "oauth") {
384
596
  return { result: null };
385
597
  }
386
598
 
@@ -390,8 +602,9 @@ export class AuthStorage {
390
602
 
391
603
  const oauthCreds: Record<string, OAuthCredentials> = {};
392
604
  for (const [key, value] of Object.entries(currentData)) {
393
- if (value.type === "oauth") {
394
- oauthCreds[key] = value;
605
+ const first = Array.isArray(value) ? value.find((c) => c.type === "oauth") : value;
606
+ if (first?.type === "oauth") {
607
+ oauthCreds[key] = first;
395
608
  }
396
609
  }
397
610
 
@@ -400,9 +613,20 @@ export class AuthStorage {
400
613
  return { result: null };
401
614
  }
402
615
 
616
+ // Update the OAuth credential in-place within the array
617
+ const existingEntry = currentData[providerId];
618
+ const newOAuthCred: OAuthCredential = { type: "oauth", ...refreshed.newCredentials };
619
+ let updatedEntry: AuthCredential | AuthCredential[];
620
+
621
+ if (Array.isArray(existingEntry)) {
622
+ updatedEntry = existingEntry.map((c) => (c.type === "oauth" ? newOAuthCred : c));
623
+ } else {
624
+ updatedEntry = newOAuthCred;
625
+ }
626
+
403
627
  const merged: AuthStorageData = {
404
628
  ...currentData,
405
- [providerId]: { type: "oauth", ...refreshed.newCredentials },
629
+ [providerId]: updatedEntry,
406
630
  };
407
631
  this.data = merged;
408
632
  this.loadError = null;
@@ -413,65 +637,71 @@ export class AuthStorage {
413
637
  }
414
638
 
415
639
  /**
416
- * Get API key for a provider.
417
- * Priority:
418
- * 1. Runtime override (CLI --api-key)
419
- * 2. API key from auth.json
420
- * 3. OAuth token from auth.json (auto-refreshed with locking)
421
- * 4. Environment variable
422
- * 5. Fallback resolver (models.json custom providers)
640
+ * Resolve an API key from a single credential.
423
641
  */
424
- async getApiKey(providerId: string): Promise<string | undefined> {
425
- // Runtime override takes highest priority
426
- const runtimeKey = this.runtimeOverrides.get(providerId);
427
- if (runtimeKey) {
428
- return runtimeKey;
429
- }
430
-
431
- const cred = this.data[providerId];
432
-
433
- if (cred?.type === "api_key") {
642
+ private async resolveCredentialApiKey(
643
+ providerId: string,
644
+ cred: AuthCredential,
645
+ ): Promise<string | undefined> {
646
+ if (cred.type === "api_key") {
434
647
  return resolveConfigValue(cred.key);
435
648
  }
436
649
 
437
- if (cred?.type === "oauth") {
650
+ if (cred.type === "oauth") {
438
651
  const provider = getOAuthProvider(providerId);
439
- if (!provider) {
440
- // Unknown OAuth provider, can't get API key
441
- return undefined;
442
- }
652
+ if (!provider) return undefined;
443
653
 
444
- // Check if token needs refresh
445
654
  const needsRefresh = Date.now() >= cred.expires;
446
-
447
655
  if (needsRefresh) {
448
- // Use locked refresh to prevent race conditions
449
656
  try {
450
657
  const result = await this.refreshOAuthTokenWithLock(providerId);
451
- if (result) {
452
- return result.apiKey;
453
- }
658
+ if (result) return result.apiKey;
454
659
  } catch (error) {
455
660
  this.recordError(error);
456
- // Refresh failed - re-read file to check if another instance succeeded
457
661
  this.reload();
458
- const updatedCred = this.data[providerId];
459
-
460
- if (updatedCred?.type === "oauth" && Date.now() < updatedCred.expires) {
461
- // Another instance refreshed successfully, use those credentials
462
- return provider.getApiKey(updatedCred);
662
+ const updatedCreds = this.getCredentialsForProvider(providerId);
663
+ const updatedOAuth = updatedCreds.find((c) => c.type === "oauth");
664
+ if (updatedOAuth?.type === "oauth" && Date.now() < updatedOAuth.expires) {
665
+ return provider.getApiKey(updatedOAuth);
463
666
  }
464
-
465
- // Refresh truly failed - return undefined so model discovery skips this provider
466
- // User can /login to re-authenticate (credentials preserved for retry)
467
667
  return undefined;
468
668
  }
469
669
  } else {
470
- // Token not expired, use current access token
471
670
  return provider.getApiKey(cred);
472
671
  }
473
672
  }
474
673
 
674
+ return undefined;
675
+ }
676
+
677
+ /**
678
+ * Get API key for a provider.
679
+ * Priority:
680
+ * 1. Runtime override (CLI --api-key)
681
+ * 2. Credential(s) from auth.json (with round-robin / session-sticky selection)
682
+ * 3. Environment variable
683
+ * 4. Fallback resolver (models.json custom providers)
684
+ *
685
+ * @param providerId - The provider to get an API key for
686
+ * @param sessionId - Optional session ID for sticky credential selection
687
+ */
688
+ async getApiKey(providerId: string, sessionId?: string): Promise<string | undefined> {
689
+ // Runtime override takes highest priority
690
+ const runtimeKey = this.runtimeOverrides.get(providerId);
691
+ if (runtimeKey) {
692
+ return runtimeKey;
693
+ }
694
+
695
+ const credentials = this.getCredentialsForProvider(providerId);
696
+
697
+ if (credentials.length > 0) {
698
+ const index = this.selectCredentialIndex(providerId, credentials, sessionId);
699
+ if (index >= 0) {
700
+ return this.resolveCredentialApiKey(providerId, credentials[index]);
701
+ }
702
+ // All credentials backed off - fall through to env/fallback
703
+ }
704
+
475
705
  // Fall back to environment variable
476
706
  const envKey = getEnvApiKey(providerId);
477
707
  if (envKey) return envKey;
@@ -517,16 +517,18 @@ export class ModelRegistry {
517
517
 
518
518
  /**
519
519
  * Get API key for a model.
520
+ * @param sessionId - Optional session ID for sticky credential selection
520
521
  */
521
- async getApiKey(model: Model<Api>): Promise<string | undefined> {
522
- return this.authStorage.getApiKey(model.provider);
522
+ async getApiKey(model: Model<Api>, sessionId?: string): Promise<string | undefined> {
523
+ return this.authStorage.getApiKey(model.provider, sessionId);
523
524
  }
524
525
 
525
526
  /**
526
527
  * Get API key for a provider.
528
+ * @param sessionId - Optional session ID for sticky credential selection
527
529
  */
528
- async getApiKeyForProvider(provider: string): Promise<string | undefined> {
529
- return this.authStorage.getApiKey(provider);
530
+ async getApiKeyForProvider(provider: string, sessionId?: string): Promise<string | undefined> {
531
+ return this.authStorage.getApiKey(provider, sessionId);
530
532
  }
531
533
 
532
534
  /**
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
3
  import { dirname, join } from "path";
4
4
  import lockfile from "proper-lockfile";
5
5
  import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
6
+ import type { BashInterceptorRule } from "./tools/bash-interceptor.js";
6
7
 
7
8
  export interface CompactionSettings {
8
9
  enabled?: boolean; // default: true
@@ -39,10 +40,20 @@ export interface ThinkingBudgetsSettings {
39
40
  high?: number;
40
41
  }
41
42
 
43
+ export interface BashInterceptorSettings {
44
+ enabled?: boolean; // default: true
45
+ rules?: BashInterceptorRule[]; // override default rules
46
+ }
47
+
42
48
  export interface MarkdownSettings {
43
49
  codeBlockIndent?: string; // default: " "
44
50
  }
45
51
 
52
+ export interface AsyncSettings {
53
+ enabled?: boolean; // default: false
54
+ maxJobs?: number; // default: 100
55
+ }
56
+
46
57
  export type TransportSetting = Transport;
47
58
 
48
59
  /**
@@ -93,6 +104,8 @@ export interface Settings {
93
104
  autocompleteMaxVisible?: number; // Max visible items in autocomplete dropdown (default: 5)
94
105
  showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME
95
106
  markdown?: MarkdownSettings;
107
+ async?: AsyncSettings;
108
+ bashInterceptor?: BashInterceptorSettings;
96
109
  }
97
110
 
98
111
  /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
@@ -357,6 +370,14 @@ export class SettingsManager {
357
370
  return structuredClone(this.projectSettings);
358
371
  }
359
372
 
373
+ getBashInterceptorEnabled(): boolean {
374
+ return this.settings.bashInterceptor?.enabled ?? true;
375
+ }
376
+
377
+ getBashInterceptorRules(): BashInterceptorRule[] | undefined {
378
+ return this.settings.bashInterceptor?.rules;
379
+ }
380
+
360
381
  reload(): void {
361
382
  const globalLoad = SettingsManager.tryLoadFromStorage(this.storage, "global");
362
383
  if (!globalLoad.error) {
@@ -939,4 +960,12 @@ export class SettingsManager {
939
960
  getCodeBlockIndent(): string {
940
961
  return this.settings.markdown?.codeBlockIndent ?? " ";
941
962
  }
963
+
964
+ getAsyncEnabled(): boolean {
965
+ return this.settings.async?.enabled ?? false;
966
+ }
967
+
968
+ getAsyncMaxJobs(): number {
969
+ return this.settings.async?.maxJobs ?? 100;
970
+ }
942
971
  }