@monotykamary/pi-retry 0.8.6 → 0.9.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.
package/README.md CHANGED
@@ -21,11 +21,11 @@ This extension automatically detects and retries **all** errors by default, with
21
21
 
22
22
  | Error Type | Retry Behavior | Use Case |
23
23
  |------------|----------------|----------|
24
- | **Any retryable error** (catch-all) | **Indefinite** with capped backoff | Everything else — provider hiccups, stream exhaustion, credit issues, unknown errors |
25
- | HTTP 400/413 | **Indefinite** with capped backoff, NO compaction | Transient context overflow that might resolve |
26
- | Credit / payment errors | **Indefinite** with capped backoff | "Not Enough Credits", insufficient balance, 402 — top up and the retry loop auto-resumes |
24
+ | **Any retryable error** (catch-all) | **Capped exponential retry** | Everything else — provider hiccups, stream exhaustion, credit issues, unknown errors |
25
+ | HTTP 400/413 | **Capped** with exponential backoff, NO compaction | Transient context overflow that might resolve |
26
+ | Credit / payment errors | **Capped** with exponential backoff | "Not Enough Credits", insufficient balance, 402 — top up and the retry loop auto-resumes |
27
27
  | **Quota / session-limit / budget exhaustion** | **Not retried** — notify + stop | "You've hit your limit", `insufficient_quota`, "out of budget", suspended accounts |
28
- | Connection errors | **Indefinite** with capped backoff | Network hiccups, connection drops, socket errors, stream exhaustion |
28
+ | Connection errors | **Capped** with exponential backoff | Network hiccups, connection drops, socket errors, stream exhaustion |
29
29
  | Max tokens (`stopReason: "length"`) | **Auto-continue** indefinitely with hidden continuation turns | Model hits output token limit mid-generation |
30
30
  | Empty / think-only stop (`stopReason: "stop"` with no text or tool calls) | **Nudge once** with a hidden continuation, then give up | Model ends its turn with no usable output (Anthropic empty responses with end_turn, thinking-only turns) |
31
31
 
@@ -43,7 +43,7 @@ By default, pi has built-in retry for some errors (rate limits, 5xx, overloaded)
43
43
 
44
44
  ## The Solution
45
45
 
46
- This extension provides **automatic** infinite retry with sensible exponential backoff (2s → 4s → 8s → ... → 60s max).
46
+ This extension provides automatic retry for all errors with configurable exponential backoff and a maximum-delay failure limit (2s → 4s → 8s → ... → 60s by default).
47
47
 
48
48
  **Philosophy: retry EVERYTHING by default.** The only things we skip are a tiny blacklist of known permanent failures (invalid API key, model not found, unsupported model, etc.).
49
49
 
@@ -51,9 +51,9 @@ This extension provides **automatic** infinite retry with sensible exponential b
51
51
  - **Catch-all retry** — Any `stopReason: "error"` is retried, regardless of error message
52
52
  - Automatic detection of 400/413, connection, credit, and stream exhaustion errors
53
53
  - **Auto-continuation** when the model hits its max output tokens (`stopReason: "length"`) — indefinite, no cap, hidden from the TUI
54
- - **Indefinite retry** — Keeps retrying until success
54
+ - **Retry cutoff** — Keeps retrying until success, abort, or the configured number of failures at the maximum delay
55
55
  - **Auto-stop on quota/budget exhaustion** — Session limits, plan quotas, and budget caps ("You've hit your limit", "out of budget", `insufficient_quota`, suspended accounts) are detected and **not** retried, with a notification explaining why
56
- - Exponential backoff with cap: max 60s between retries
56
+ - Exponential backoff with configurable base delay, cap, multiplier, and maximum-delay failure count
57
57
  - **Hidden triggers** — provider-valid custom messages use `display: false`, so retries do not add TUI clutter
58
58
  - Manual controls via unified `/retry` command
59
59
  - Non-retryable errors are explicitly logged so you know why we didn't retry
@@ -121,15 +121,35 @@ Once loaded, the extension **automatically** detects and retries all errors.
121
121
 
122
122
  ## Configuration
123
123
 
124
- Edit the constants at the top of `retry.ts`:
124
+ The extension reads a `piRetry` object from Pi's settings files:
125
125
 
126
- ```typescript
127
- const BASE_DELAY_MS = 2000; // Start with 2 seconds
128
- const MAX_DELAY_MS = 60000; // Cap at 60 seconds
129
- const BACKOFF_MULTIPLIER = 2; // Double each time
130
- // Continuations use a hidden provider-valid custom message
126
+ - `~/.pi/agent/settings.json` applies globally.
127
+ - `.pi/settings.json` overrides matching global values for the current project.
128
+
129
+ ```json
130
+ {
131
+ "piRetry": {
132
+ "baseDelayMs": 10000,
133
+ "maxDelayMs": 3600000,
134
+ "multiplier": 2,
135
+ "maxRetriesAtMaxDelay": 3
136
+ }
137
+ }
131
138
  ```
132
139
 
140
+ The example above waits 10 seconds before the first retry, doubles each delay, caps the delay at one hour, and stops after three failed retries at that cap. Supported values are:
141
+
142
+ | Setting | Default | Description |
143
+ |---------|---------|-------------|
144
+ | `baseDelayMs` | `2000` | Delay before the first retry, in milliseconds |
145
+ | `maxDelayMs` | `60000` | Maximum delay between retries, in milliseconds |
146
+ | `multiplier` | `2` | Exponential backoff multiplier; must be at least `1` |
147
+ | `maxRetriesAtMaxDelay` | `3` | Failed ordinary retries allowed after the delay reaches `maxDelayMs` |
148
+
149
+ `piRetry` is separate from Pi's built-in `retry` object so the two retry policies do not share ambiguous settings. The extension disables Pi's native retry scheduler while it is loaded, while preserving Pi's compaction handling, so only one retry loop owns the backoff schedule.
150
+
151
+ Settings are read when the extension starts. Restart pi or use `/reload` after editing them.
152
+
133
153
  ---
134
154
 
135
155
  ## How It Works
@@ -234,11 +254,13 @@ npm run lint:dead
234
254
  .
235
255
  ├── retry.ts # Main unified extension
236
256
  ├── src/ # Shared utilities (testable, DRY)
257
+ │ ├── config.ts # Settings-backed retry configuration
237
258
  │ ├── error-patterns.ts # Error pattern matching, custom types, hasMaxTokensStop
238
259
  │ ├── retry-logic.ts # Retry utilities (calculateDelay, RetryState, ContinuationState, etc.)
239
260
  │ └── index.ts # Barrel exports
240
261
  ├── __tests__/ # Unit tests
241
262
  │ └── unit/
263
+ │ ├── config.test.ts
242
264
  │ ├── error-patterns.test.ts
243
265
  │ └── retry-logic.test.ts
244
266
  ├── vitest.config.ts # Test configuration
@@ -249,7 +271,7 @@ npm run lint:dead
249
271
 
250
272
  ```bash
251
273
  # Run all quality checks
252
- npm test # 99 unit tests
274
+ npm test # 224 tests
253
275
  npm run typecheck # TypeScript type checking
254
276
  npm run lint:dead # Dead code detection with knip
255
277
  ```
@@ -302,7 +324,7 @@ pi install npm:@georgebashi/pi-retry
302
324
 
303
325
  ## Limitations
304
326
 
305
- - Extensions cannot override pi's internal `isRetryableError()` check — they run *after* pi decides not to auto-retry
327
+ - Pi's native retry scheduler is disabled while this extension is loaded so native and extension retries cannot interleave; Pi's compaction check still runs normally
306
328
  - Error messages remain in the session history (but are invisible to the LLM)
307
329
  - May hit the same error repeatedly if the issue is persistent (use `Ctrl+C` to abort)
308
330
  - **Warning**: Retrying 400/413 without reducing context may fail repeatedly if the payload is genuinely too large
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-retry",
3
- "version": "0.8.6",
3
+ "version": "0.9.0",
4
4
  "description": "Extension suite for pi coding agent that handles 400/413 errors and connection errors with automatic retry",
5
5
  "type": "module",
6
6
  "author": "Tom X Nguyen",
package/retry.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  isAssistantMessage,
17
17
  getLastAssistantMessage,
18
18
  calculateDelay,
19
+ loadPiRetryConfig,
19
20
  formatDuration,
20
21
  getErrorCategory,
21
22
  RetryState,
@@ -32,7 +33,8 @@ const RETRY_CANCELLED_EVENT = "pi-retry:cancelled";
32
33
  * Unified retry extension — retries EVERY error by default.
33
34
  *
34
35
  * Philosophy: any assistant message with stopReason === "error" is retried
35
- * indefinitely with exponential backoff, except a small blacklist of known
36
+ * with exponential backoff capped by settings, then stops after the configured
37
+ * number of failures at the maximum delay, except a small blacklist of known
36
38
  * permanent failures and hard-stop conditions (invalid API key, model not
37
39
  * found, quota/session-limit/budget exhaustion, suspended accounts, etc.).
38
40
  *
@@ -41,7 +43,7 @@ const RETRY_CANCELLED_EVENT = "pi-retry:cancelled";
41
43
  *
42
44
  * Features:
43
45
  * - Automatic detection and retry for ALL errors (catch-all)
44
- * - Indefinite retry with exponential backoff (capped at 60s)
46
+ * - Retry with exponential backoff and a configurable cap/failure limit
45
47
  * - Auto-continuation when model hits max output tokens (stopReason "length")
46
48
  * - Retry triggers are hidden in the TUI and serialized as provider-valid user turns
47
49
  * - Unified manual controls via /retry command
@@ -73,25 +75,15 @@ Agent.prototype.subscribe = function (this: Agent, ...args: any[]) {
73
75
  return _origSubscribe.apply(this, args);
74
76
  };
75
77
 
76
- // Monkey-patch AgentSession._prepareRetry to suppress the built-in retry
77
- // when pi-retry's loop is driving. Without this, both the built-in retry
78
- // and pi-retry race to handle the same error: the built-in retry counts
79
- // 3 failed attempts and shows "Retry failed after 3 attempts: ...",
80
- // while pi-retry is still looping indefinitely in the background.
81
- //
82
- // When _continueInProgress is true (pi-retry is running), _prepareRetry
83
- // returns false immediately, so _handlePostAgentRun falls through to
84
- // the compaction check and the while loop in _runAgentPrompt exits
85
- // cleanly. No auto_retry_start/end events, no "Retry failed" message.
86
- //
87
- // When _continueInProgress is false (pi-retry is not active), the
88
- // built-in retry works normally as a fallback.
78
+ // AgentSession runs _prepareRetry inside _runAgentPrompt before it emits
79
+ // agent_end. If native retry remains enabled, the first error is retried
80
+ // before pi-retry can claim it, so the two retry loops interleave and produce
81
+ // non-monotonic delays. pi-retry owns retry scheduling for the whole session;
82
+ // returning false still lets AgentSession run its compaction check.
83
+ let _piRetryActive = false;
89
84
  const _origPrepareRetry = (AgentSession.prototype as any)._prepareRetry;
90
85
  (AgentSession.prototype as any)._prepareRetry = function(this: any, message: any) {
91
- if (
92
- _continueInProgress &&
93
- _continueInputGeneration === _inputGeneration
94
- ) {
86
+ if (_piRetryActive) {
95
87
  return Promise.resolve(false);
96
88
  }
97
89
  return _origPrepareRetry.call(this, message);
@@ -170,20 +162,20 @@ function interruptibleSleep(
170
162
  // Same technique used by the built-in retry in _prepareRetry — the error
171
163
  // message stays in the session journal for history but is removed from the
172
164
  // agent's live transcript so the LLM receives a clean context on retry.
173
- function removeErrorFromAgentState(): void {
174
- if (!_agent) return;
175
- const messages = _agent.state.messages;
165
+ function removeErrorFromAgentState(agent: Agent | null = _agent): void {
166
+ if (!agent) return;
167
+ const messages = agent.state.messages;
176
168
  const lastMsg = messages[messages.length - 1];
177
169
  if (lastMsg?.role === 'assistant' && lastMsg.stopReason === 'error') {
178
- _agent.state.messages = messages.slice(0, -1);
170
+ agent.state.messages = messages.slice(0, -1);
179
171
  }
180
172
  }
181
173
 
182
174
  type HiddenTurnKind = "retry" | "continue" | "empty";
183
175
 
184
- function getHiddenTurnKind(): HiddenTurnKind | null {
185
- if (!_agent) return null;
186
- const messages = _agent.state.messages;
176
+ function getHiddenTurnKind(agent: Agent | null = _agent): HiddenTurnKind | null {
177
+ if (!agent) return null;
178
+ const messages = agent.state.messages;
187
179
  const lastMsg = messages[messages.length - 1];
188
180
  if (lastMsg?.role !== "assistant") return null;
189
181
  if (lastMsg.stopReason === "error") return "retry";
@@ -197,6 +189,11 @@ function lastMessageIsRetryableError(): boolean {
197
189
  }
198
190
 
199
191
  export default function (pi: ExtensionAPI) {
192
+ let _notifyFn: ((message: string, level: "info" | "warning" | "error") => void) | null = null;
193
+
194
+ // Mark native retry as owned by this extension before the first agent turn.
195
+ _piRetryActive = true;
196
+ const retryConfig = loadPiRetryConfig();
200
197
 
201
198
  pi.on("input", () => {
202
199
  _inputGeneration++;
@@ -423,10 +420,11 @@ export default function (pi: ExtensionAPI) {
423
420
 
424
421
  // Config
425
422
  status += "Configuration:\n";
426
- status += ` Base delay: 2000ms\n`;
427
- status += ` Max delay: 60000ms\n`;
428
- status += ` Backoff multiplier: 2\n`;
429
- status += ` Retry loop: infinite (triggerInvisibleContinue loops until success or abort)\n\n`;
423
+ status += ` Base delay: ${retryConfig.baseDelayMs}ms\n`;
424
+ status += ` Max delay: ${retryConfig.maxDelayMs}ms\n`;
425
+ status += ` Backoff multiplier: ${retryConfig.multiplier}\n`;
426
+ status += ` Max-delay failures: ${retryConfig.maxRetriesAtMaxDelay}\n`;
427
+ status += " Retry loop: until success, abort, or the max-delay failure limit\n\n";
430
428
 
431
429
  // Last assistant info
432
430
  if (lastAssistant && isAssistantMessage(lastAssistant)) {
@@ -541,6 +539,21 @@ export default function (pi: ExtensionAPI) {
541
539
  }
542
540
  });
543
541
 
542
+ // Handle session replacement before Pi invalidates this extension runtime.
543
+ // A retry loop may still be awaiting a timer or AgentSession turn when the
544
+ // old session shuts down, so release its ownership and invalidate its
545
+ // generation before the captured pi object becomes stale.
546
+ pi.on("session_shutdown", () => {
547
+ _sessionGeneration++;
548
+ _userAborted = true;
549
+ _continueInProgress = false;
550
+ _continueGeneration = null;
551
+ _continueInputGeneration = null;
552
+ _notifyFn = null;
553
+ _terminalInputUnsubscribe?.();
554
+ _terminalInputUnsubscribe = null;
555
+ });
556
+
544
557
  // Initialize
545
558
  pi.on("session_start", async (_event, ctx) => {
546
559
  // Bump the generation counter so any in-flight retry loop from a
@@ -558,6 +571,7 @@ export default function (pi: ExtensionAPI) {
558
571
  // finally block releases its owner token. Resetting it here could allow
559
572
  // a second loop to start before the old one has settled.
560
573
  _userAborted = false;
574
+ _notifyFn = null;
561
575
 
562
576
  _terminalInputUnsubscribe?.();
563
577
  _terminalInputUnsubscribe = null;
@@ -600,7 +614,10 @@ export default function (pi: ExtensionAPI) {
600
614
  // agent.state.messages so the LLM receives a clean context (same
601
615
  // technique as the built-in retry's _prepareRetry).
602
616
  async function triggerInvisibleContinue(initialKind: HiddenTurnKind) {
603
- if (!_agent) return;
617
+ // Keep the AgentSession that started this loop. A replacement session can
618
+ // update the module-level reference before an old loop has unwound.
619
+ const myAgent = _agent;
620
+ if (!myAgent) return;
604
621
 
605
622
  // Guard: if the user aborted, do not queue another retry turn.
606
623
  if (_userAborted) return;
@@ -610,7 +627,6 @@ export default function (pi: ExtensionAPI) {
610
627
  _continueInProgress = true;
611
628
  const retryLifecycleId = ++_retryLifecycleId;
612
629
  let didRetryComplete = false;
613
- pi.events.emit(RETRY_STARTED_EVENT, { retryId: retryLifecycleId });
614
630
 
615
631
  // Capture the current session generation. If /new fires while we're
616
632
  // looping, _sessionGeneration will increment and the loop will exit.
@@ -620,9 +636,11 @@ export default function (pi: ExtensionAPI) {
620
636
  _continueInputGeneration = myInputGeneration;
621
637
 
622
638
  try {
639
+ emitRetryLifecycleEvent(RETRY_STARTED_EVENT, retryLifecycleId);
640
+
623
641
  // Wait for the current run to finish (activeRun resolves in
624
642
  // finishRun() after agent_end listeners return).
625
- await _agent.waitForIdle();
643
+ await myAgent.waitForIdle();
626
644
 
627
645
  // Re-check after waitForIdle: the user may have aborted or the
628
646
  // session may have changed while we were waiting.
@@ -634,9 +652,13 @@ export default function (pi: ExtensionAPI) {
634
652
 
635
653
  let attempt = 0;
636
654
  let hiddenTurnKind: HiddenTurnKind | null = initialKind;
637
- // Empty-stop nudges are bounded: MAX_EMPTY_CONTINUATIONS total
638
- // continuation requests, then we give up (the model decided it is done).
655
+ // Empty-stop nudges are bounded: a model that produced no usable output
656
+ // and answers the nudge with another empty turn is decided, not stalled.
657
+ // Stop after MAX_EMPTY_CONTINUATIONS rather than looping forever.
639
658
  let emptyNudges = 0;
659
+ // Only ordinary retries count toward the maximum-delay cutoff; token
660
+ // continuations intentionally remain uncapped.
661
+ let maxDelayRetries = 0;
640
662
 
641
663
  // Loop until success, abort, or session change.
642
664
  while (true) {
@@ -653,14 +675,14 @@ export default function (pi: ExtensionAPI) {
653
675
  didRetryComplete = true;
654
676
  return;
655
677
  }
656
- removeErrorFromAgentState();
678
+ removeErrorFromAgentState(myAgent);
657
679
 
658
680
  // Empty-stop cap: a model that produced no usable output and answers
659
681
  // the nudge with another empty turn is decided, not stalled. Stop
660
682
  // after MAX_EMPTY_CONTINUATIONS rather than looping forever.
661
683
  if (hiddenTurnKind === "empty") {
662
684
  if (emptyNudges >= MAX_EMPTY_CONTINUATIONS) {
663
- _notifyFn?.(
685
+ notifySafely(
664
686
  `Empty response after ${emptyNudges} continuation(s) - giving up (model keeps ending the turn with no output).`,
665
687
  "warning",
666
688
  );
@@ -670,7 +692,11 @@ export default function (pi: ExtensionAPI) {
670
692
  }
671
693
 
672
694
  attempt++;
673
- const delay = calculateDelay(attempt);
695
+ const delay = calculateDelay(attempt, retryConfig);
696
+ const isMaxDelay = delay >= retryConfig.maxDelayMs;
697
+ if (hiddenTurnKind === "retry" && isMaxDelay) {
698
+ maxDelayRetries++;
699
+ }
674
700
 
675
701
  // Notify the user about the upcoming retry attempt.
676
702
  _notifyRetryAttempt(attempt, delay);
@@ -678,7 +704,7 @@ export default function (pi: ExtensionAPI) {
678
704
  // Interruptible sleep with backoff BEFORE the retry attempt.
679
705
  // Polls _userAborted and _sessionGeneration every 100ms so ESC
680
706
  // and /new take effect within 100ms instead of waiting for the
681
- // full backoff (up to 60s).
707
+ // full backoff (up to the configured maximum delay).
682
708
  const interrupted = await interruptibleSleep(
683
709
  delay,
684
710
  myGeneration,
@@ -707,7 +733,7 @@ export default function (pi: ExtensionAPI) {
707
733
  // low-level run synchronously before returning. Waiting on Agent
708
734
  // keeps this retry loop intact without bypassing session state.
709
735
  await Promise.resolve();
710
- await _agent.waitForIdle();
736
+ await myAgent.waitForIdle();
711
737
  } catch {
712
738
  return;
713
739
  }
@@ -722,21 +748,41 @@ export default function (pi: ExtensionAPI) {
722
748
 
723
749
  // The hidden AgentSession turn completed. Both errors and output
724
750
  // length stops need another turn; all other terminal states are done.
725
- hiddenTurnKind = getHiddenTurnKind();
751
+ hiddenTurnKind = getHiddenTurnKind(myAgent);
726
752
  if (!hiddenTurnKind) {
727
753
  didRetryComplete = true;
728
754
  return;
729
755
  }
756
+
757
+ // Stop ordinary retries after the configured number of failures at
758
+ // the cap. The failed capped turns have already been delivered; this
759
+ // check prevents scheduling one more capped request.
760
+ if (
761
+ hiddenTurnKind === "retry" &&
762
+ maxDelayRetries >= retryConfig.maxRetriesAtMaxDelay
763
+ ) {
764
+ notifySafely(
765
+ `Retry failed ${retryConfig.maxRetriesAtMaxDelay} times at the maximum backoff (${formatDuration(retryConfig.maxDelayMs)}); giving up.`,
766
+ "warning",
767
+ );
768
+ return;
769
+ }
730
770
  }
731
771
  } finally {
732
- // Release the mutex only if this loop still owns it.
772
+ // Release the mutex only if this loop still owns it. If the session was
773
+ // replaced, session_shutdown clears ownership and suppresses the event
774
+ // because the captured pi.events bus is stale by this point.
733
775
  if (_continueGeneration === myGeneration) {
734
- pi.events.emit(didRetryComplete ? RETRY_COMPLETED_EVENT : RETRY_CANCELLED_EVENT, {
735
- retryId: retryLifecycleId,
736
- });
776
+ const sessionIsCurrent = _sessionGeneration === myGeneration;
737
777
  _continueInProgress = false;
738
778
  _continueGeneration = null;
739
779
  _continueInputGeneration = null;
780
+ if (sessionIsCurrent) {
781
+ emitRetryLifecycleEvent(
782
+ didRetryComplete ? RETRY_COMPLETED_EVENT : RETRY_CANCELLED_EVENT,
783
+ retryLifecycleId,
784
+ );
785
+ }
740
786
  }
741
787
  }
742
788
  }
@@ -745,7 +791,34 @@ export default function (pi: ExtensionAPI) {
745
791
  // ctx.ui.notify is only available inside event handlers, not inside
746
792
  // triggerInvisibleContinue. We capture a fresh reference from the
747
793
  // most recent handler invocation so it's always current.
748
- let _notifyFn: ((message: string, level: "info" | "warning" | "error") => void) | null = null;
794
+
795
+ function isStaleContextError(error: unknown): boolean {
796
+ return error instanceof Error && error.message.includes("This extension ctx is stale");
797
+ }
798
+
799
+ function emitRetryLifecycleEvent(event: string, retryId: number): void {
800
+ try {
801
+ pi.events.emit(event, { retryId });
802
+ } catch (error) {
803
+ // Session replacement invalidates the old event bus while a retry loop
804
+ // can still be unwinding. There is no live listener to notify then.
805
+ if (!isStaleContextError(error)) throw error;
806
+ }
807
+ }
808
+
809
+ function notifySafely(message: string, level: "info" | "warning" | "error"): void {
810
+ if (!_notifyFn) return;
811
+ try {
812
+ _notifyFn(message, level);
813
+ } catch (error) {
814
+ // The notification closure can outlive the session that supplied its ctx.
815
+ if (isStaleContextError(error)) {
816
+ _notifyFn = null;
817
+ return;
818
+ }
819
+ throw error;
820
+ }
821
+ }
749
822
 
750
823
  // Refresh on every handler that carries a ctx — stale references
751
824
  // break after session switches (the old ctx becomes invalid).
@@ -760,9 +833,7 @@ export default function (pi: ExtensionAPI) {
760
833
  });
761
834
 
762
835
  function _notifyRetryAttempt(attempt: number, delayMs: number) {
763
- if (_notifyFn) {
764
- const duration = formatDuration(delayMs);
765
- _notifyFn(`Retry attempt ${attempt} (backoff ${duration})...`, "info");
766
- }
836
+ const duration = formatDuration(delayMs);
837
+ notifySafely(`Retry attempt ${attempt} (backoff ${duration})...`, "info");
767
838
  }
768
839
  }
package/src/config.ts ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Settings-backed configuration for the pi-retry extension.
3
+ */
4
+
5
+ import { readFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import {
9
+ DEFAULT_BACKOFF_CONFIG,
10
+ type BackoffConfig,
11
+ } from "./retry-logic.js";
12
+
13
+ /**
14
+ * Complete retry configuration used by the extension.
15
+ */
16
+ export interface PiRetryConfig extends BackoffConfig {
17
+ /** Number of failed retries allowed after the delay reaches maxDelayMs. */
18
+ maxRetriesAtMaxDelay: number;
19
+ }
20
+
21
+ /**
22
+ * Default values preserve the extension's existing behavior while making it
23
+ * possible to tune the schedule through settings.json.
24
+ */
25
+ export const DEFAULT_RETRY_CONFIG: PiRetryConfig = {
26
+ ...DEFAULT_BACKOFF_CONFIG,
27
+ maxRetriesAtMaxDelay: 3,
28
+ };
29
+
30
+ /** Root settings key owned by this extension. */
31
+ export const PI_RETRY_SETTINGS_KEY = "piRetry";
32
+
33
+ type SettingsObject = Record<string, unknown>;
34
+
35
+ /**
36
+ * Check whether a decoded JSON value can be read as a settings object.
37
+ *
38
+ * @param value Decoded JSON value to inspect.
39
+ * @returns True when the value is a non-array object.
40
+ */
41
+ function isSettingsObject(value: unknown): value is SettingsObject {
42
+ return typeof value === "object" && value !== null && !Array.isArray(value);
43
+ }
44
+
45
+ /**
46
+ * Read one numeric setting and fall back when it fails validation.
47
+ *
48
+ * @param settings Settings namespace to inspect.
49
+ * @param key Setting name to read.
50
+ * @param fallback Default value used for invalid or missing input.
51
+ * @param isValid Predicate for the accepted numeric range.
52
+ * @returns A validated number.
53
+ */
54
+ function readNumberSetting(
55
+ settings: SettingsObject,
56
+ key: string,
57
+ fallback: number,
58
+ isValid: (value: number) => boolean,
59
+ ): number {
60
+ const value = settings[key];
61
+ if (value === undefined) return fallback;
62
+ if (typeof value === "number" && Number.isFinite(value) && isValid(value)) {
63
+ return value;
64
+ }
65
+
66
+ // Invalid settings should not prevent pi from starting; use the field's
67
+ // default and make the configuration error visible to the user.
68
+ console.warn(
69
+ `[pi-retry] Ignoring invalid ${PI_RETRY_SETTINGS_KEY}.${key} value: ${String(value)}`,
70
+ );
71
+ return fallback;
72
+ }
73
+
74
+ /**
75
+ * Extract the extension namespace from a decoded settings file.
76
+ *
77
+ * @param settings Decoded settings file contents.
78
+ * @returns The piRetry namespace, or an empty object when absent/invalid.
79
+ */
80
+ function getRetrySettings(settings: unknown): SettingsObject {
81
+ if (!isSettingsObject(settings)) return {};
82
+ const retrySettings = settings[PI_RETRY_SETTINGS_KEY];
83
+ return isSettingsObject(retrySettings) ? retrySettings : {};
84
+ }
85
+
86
+ /**
87
+ * Merge global and project namespaces, then validate every supported field.
88
+ *
89
+ * @param globalSettings Decoded global settings.json contents.
90
+ * @param projectSettings Decoded project settings.json contents.
91
+ * @returns The validated effective retry configuration.
92
+ *
93
+ * TEST:__tests__/unit/config.test.ts[resolvePiRetryConfig]
94
+ */
95
+ export function resolvePiRetryConfig(
96
+ globalSettings: unknown,
97
+ projectSettings: unknown,
98
+ ): PiRetryConfig {
99
+ const mergedSettings = {
100
+ ...getRetrySettings(globalSettings),
101
+ ...getRetrySettings(projectSettings),
102
+ };
103
+
104
+ return {
105
+ baseDelayMs: readNumberSetting(
106
+ mergedSettings,
107
+ "baseDelayMs",
108
+ DEFAULT_RETRY_CONFIG.baseDelayMs,
109
+ value => value >= 0,
110
+ ),
111
+ maxDelayMs: readNumberSetting(
112
+ mergedSettings,
113
+ "maxDelayMs",
114
+ DEFAULT_RETRY_CONFIG.maxDelayMs,
115
+ value => value >= 0,
116
+ ),
117
+ multiplier: readNumberSetting(
118
+ mergedSettings,
119
+ "multiplier",
120
+ DEFAULT_RETRY_CONFIG.multiplier,
121
+ value => value >= 1,
122
+ ),
123
+ maxRetriesAtMaxDelay: readNumberSetting(
124
+ mergedSettings,
125
+ "maxRetriesAtMaxDelay",
126
+ DEFAULT_RETRY_CONFIG.maxRetriesAtMaxDelay,
127
+ value => Number.isInteger(value) && value >= 1,
128
+ ),
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Load and merge Pi's global and project settings files.
134
+ *
135
+ * @param cwd Project working directory containing .pi/settings.json.
136
+ * @param homeDirectory Home directory containing .pi/agent/settings.json.
137
+ * @returns The validated effective retry configuration.
138
+ *
139
+ * TEST:__tests__/unit/config.test.ts[loadPiRetryConfig]
140
+ */
141
+ export function loadPiRetryConfig(
142
+ cwd: string = process.cwd(),
143
+ homeDirectory: string = homedir(),
144
+ ): PiRetryConfig {
145
+ const globalSettings = readSettingsFile(
146
+ join(homeDirectory, ".pi", "agent", "settings.json"),
147
+ );
148
+ const projectSettings = readSettingsFile(join(cwd, ".pi", "settings.json"));
149
+ return resolvePiRetryConfig(globalSettings, projectSettings);
150
+ }
151
+
152
+ /**
153
+ * Read a settings file without making startup dependent on optional files.
154
+ *
155
+ * @param filePath Absolute settings file path.
156
+ * @returns Decoded JSON contents, or an empty object when unavailable.
157
+ */
158
+ function readSettingsFile(filePath: string): unknown {
159
+ try {
160
+ return JSON.parse(readFileSync(filePath, "utf8")) as unknown;
161
+ } catch (error) {
162
+ const code = isSettingsObject(error) && typeof error.code === "string"
163
+ ? error.code
164
+ : undefined;
165
+ if (code !== "ENOENT") {
166
+ console.warn(
167
+ `[pi-retry] Could not read settings file ${filePath}: ${String(error)}`,
168
+ );
169
+ }
170
+ return {};
171
+ }
172
+ }
package/src/index.ts CHANGED
@@ -6,5 +6,6 @@
6
6
  * - Retry logic (exponential backoff, state management)
7
7
  */
8
8
 
9
+ export * from './config.js';
9
10
  export * from './error-patterns.js';
10
11
  export * from './retry-logic.js';