pi-goal-x 0.30.1 → 0.30.3

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
@@ -2,6 +2,15 @@
2
2
  <img src="pi-goal-x.png" alt="pi-goal-x logo" width="560">
3
3
  </div>
4
4
 
5
+ <div align="center">
6
+ <a href="https://pi.dev/packages" target="_blank" rel="noopener noreferrer">
7
+ <picture>
8
+ <source media="(prefers-color-scheme: dark)" srcset="assets/badge-dark.svg">
9
+ <img src="assets/badge-light.svg" alt="TOP 1% of Pi coding agent packages: #45 of 5,380 by downloads, as of Aug 2026" width="420">
10
+ </picture>
11
+ </a>
12
+ </div>
13
+
5
14
  # pi-goal-x
6
15
 
7
16
  `pi-goal-x` is a goal-management extension for [pi](https://github.com/earendil-works/pi-coding-agent).
@@ -533,14 +533,20 @@ export function registerGoalEvents(core: GoalCore): void {
533
533
  return;
534
534
  }
535
535
  if (!networkErrorGoalId || !core.isActionableContinuationGoal(networkErrorGoalId)) return;
536
- const plan = core.runtime.scheduleNetworkErrorRetry(ctx, core.state.goal!);
536
+ const recovery = loadGoalSettings(ctx.cwd).networkRecovery;
537
+ const policy = recovery
538
+ ? { maxAttempts: recovery.maxAttempts, maxDelayMs: recovery.maxDelayMs }
539
+ : undefined;
540
+ const plan = core.runtime.scheduleNetworkErrorRetry(ctx, core.state.goal!, policy);
537
541
  if (plan) {
542
+ const cap = plan.maxAttempts > 0 ? `/${plan.maxAttempts}` : ", unbounded";
538
543
  ctx.ui.notify(
539
- `Provider network error. Retrying the goal in ${Math.round(plan.delayMs / 1000)}s (recovery ${plan.attempt}/${plan.maxAttempts}).`,
544
+ `Provider network error. Retrying the goal in ${Math.round(plan.delayMs / 1000)}s (recovery ${plan.attempt}${cap}).`,
540
545
  "warning",
541
546
  );
542
547
  return;
543
548
  }
549
+ // Only reachable under a configured bounded cap (maxAttempts > 0).
544
550
  ctx.ui.notify(
545
551
  "Provider network errors persisted after all recovery attempts. The goal remains active; resume it when the provider is healthy.",
546
552
  "warning",
@@ -226,14 +226,36 @@ export function isErrorAssistantMessage(message: unknown): boolean {
226
226
  return raw?.role === "assistant" && raw.stopReason === "error";
227
227
  }
228
228
 
229
- /** A provider-declared network failure is safe for the bounded goal backoff. */
229
+ /**
230
+ * Transient provider failures are safe for goal-level recovery backoff:
231
+ * Pi-declared network errors plus HTTP 5xx-style outages surfaced by
232
+ * providers (503 server_error, "Endpoint is unavailable", overloaded,
233
+ * gateway failures). Deliberately excludes 4xx client errors such as auth
234
+ * and malformed-request failures, which retrying cannot fix.
235
+ */
236
+ const TRANSIENT_PROVIDER_ERROR_RE = new RegExp(
237
+ [
238
+ "\\bnetwork[_\\s-]?error\\b",
239
+ "\\bserver[_\\s]?error\\b",
240
+ "\\b(?:502|503|504|529)\\b",
241
+ "\\bservice unavailable\\b",
242
+ "\\bbad gateway\\b",
243
+ "\\bgateway timeout\\b",
244
+ "\\bupstream request failed\\b",
245
+ "\\bendpoint is unavailable\\b",
246
+ "\\boverloaded[_\\s]?error\\b",
247
+ ].join("|"),
248
+ "i",
249
+ );
250
+
251
+ /** A transient provider failure is safe for the bounded goal backoff. */
230
252
  export function isNetworkErrorAssistantMessage(message: unknown): boolean {
231
253
  if (!isErrorAssistantMessage(message)) return false;
232
254
  const raw = asRecord(message);
233
255
  const details = [raw?.rawStopReason, raw?.errorMessage]
234
256
  .filter((value): value is string => typeof value === "string")
235
257
  .join(" ");
236
- return /\bnetwork[_\s-]?error\b/i.test(details);
258
+ return TRANSIENT_PROVIDER_ERROR_RE.test(details);
237
259
  }
238
260
 
239
261
  export function isToolUseAssistantMessage(message: unknown): boolean {
@@ -12,7 +12,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import type { GoalCheckpointDetailsV2, GoalRecord } from "./goal-record.ts";
13
13
  import { checkpointTriggerPrompt } from "./prompts/goal-prompts.ts";
14
14
  import { POST_STOP_ALLOWED_TOOLS } from "./goal-tool-names.ts";
15
- import { networkErrorBackoffPlan, type NetworkErrorBackoffPlan } from "./network-error-backoff.ts";
15
+ import { networkErrorBackoffPlan, type NetworkErrorBackoffPlan, type NetworkErrorRecoveryPolicy } from "./network-error-backoff.ts";
16
16
 
17
17
  export const CONTINUATION_IDLE_RETRY_MS = 50;
18
18
 
@@ -111,13 +111,13 @@ export class GoalRuntime {
111
111
  * have failed. The counter stays in memory and is cleared on a successful
112
112
  * turn or any user-owned cancellation path.
113
113
  */
114
- scheduleNetworkErrorRetry(ctx: ExtensionContext, goal: GoalRecord): NetworkErrorBackoffPlan | null {
114
+ scheduleNetworkErrorRetry(ctx: ExtensionContext, goal: GoalRecord, policy?: NetworkErrorRecoveryPolicy): NetworkErrorBackoffPlan | null {
115
115
  if (goal.status !== "active" || !goal.autoContinue || this.networkErrorRetryTimer) return null;
116
116
  if (this.networkErrorRetryGoalId !== goal.id) {
117
117
  this.networkErrorRetryGoalId = goal.id;
118
118
  this.networkErrorRetryAttempt = 0;
119
119
  }
120
- const plan = networkErrorBackoffPlan(this.networkErrorRetryAttempt + 1);
120
+ const plan = networkErrorBackoffPlan(this.networkErrorRetryAttempt + 1, policy);
121
121
  if (!plan) return null;
122
122
  this.networkErrorRetryAttempt = plan.attempt;
123
123
  this.networkErrorRetryTimer = setTimeout(() => {
@@ -62,6 +62,12 @@ export const DEFAULT_GOAL_KEYBINDINGS: GoalKeybindings = {
62
62
  },
63
63
  };
64
64
 
65
+ /**
66
+ * Default ceiling for a single recovery delay; the escalation ladder
67
+ * plateaus here. Configurable via networkRecovery.maxDelayMs.
68
+ */
69
+ export const DEFAULT_NETWORK_RECOVERY_MAX_DELAY_MS = 80_000;
70
+
65
71
  export function formatGoalKeybinding(key: string): string {
66
72
  return key.split("+").map((part) => ({
67
73
  ctrl: "Ctrl",
@@ -108,6 +114,11 @@ export interface GoalSettingsResolvedShape {
108
114
  hideUnfocusedBanner?: boolean;
109
115
  /** Issue #26: opt-in read-only blocker Oracle configuration (sparse). */
110
116
  oracle?: GoalOracleSettingsLayer;
117
+ /**
118
+ * Goal-level provider-error recovery backoff (sparse). maxAttempts 0 or
119
+ * unset = unbounded retry (default); maxDelayMs caps the delay plateau.
120
+ */
121
+ networkRecovery?: GoalNetworkRecoverySettingsLayer;
111
122
  }
112
123
 
113
124
  // Resolved Oracle settings are attached to the resolved runtime shape below
@@ -126,7 +137,6 @@ export interface GoalOracleSettingsLayer {
126
137
  maxFailedAttemptsPerBlocker?: number;
127
138
  }
128
139
 
129
- /** Resolved Oracle settings (concrete defaults). */
130
140
  export interface ResolvedGoalOracleSettings {
131
141
  enabled: boolean;
132
142
  provider?: string;
@@ -136,6 +146,18 @@ export interface ResolvedGoalOracleSettings {
136
146
  maxFailedAttemptsPerBlocker: number;
137
147
  }
138
148
 
149
+ /** Sparse per-leaf network-recovery settings (maxAttempts 0 = unbounded). */
150
+ export interface GoalNetworkRecoverySettingsLayer {
151
+ maxAttempts?: number;
152
+ maxDelayMs?: number;
153
+ }
154
+
155
+ /** Resolved network-recovery settings (concrete defaults). */
156
+ export interface ResolvedGoalNetworkRecoverySettings {
157
+ maxAttempts: number;
158
+ maxDelayMs: number;
159
+ }
160
+
139
161
  /**
140
162
  * Sparse settings layer: exactly what a settings FILE may contain. Every
141
163
  * field optional; explicit false/0 preserved so lower layers can override
@@ -151,6 +173,8 @@ export interface GoalSettingsLayer extends GoalSettingsResolvedShape {}
151
173
  export interface ResolvedGoalSettingsShape extends GoalSettingsResolvedShape {
152
174
  /** Issue #26: resolved opt-in blocker Oracle configuration. */
153
175
  oracle?: ResolvedGoalOracleSettings;
176
+ /** Resolved goal-level provider-error recovery configuration. */
177
+ networkRecovery?: ResolvedGoalNetworkRecoverySettings;
154
178
  }
155
179
 
156
180
  export type ResolvedGoalSettings = ResolvedGoalSettingsShape;
@@ -306,8 +330,11 @@ const ALLOWED_SETTINGS_KEYS = new Set([
306
330
  "keybindings",
307
331
  "hideUnfocusedBanner",
308
332
  "oracle",
333
+ "networkRecovery",
309
334
  ]);
310
335
 
336
+ const ALLOWED_NETWORK_RECOVERY_KEYS = new Set(["maxAttempts", "maxDelayMs"]);
337
+
311
338
  const ALLOWED_ORACLE_KEYS = new Set([
312
339
  "enabled",
313
340
  "provider",
@@ -475,6 +502,31 @@ export function parseSettingsLayer(
475
502
  layer.oracle = oracle;
476
503
  break;
477
504
  }
505
+ case "networkRecovery": {
506
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
507
+ diagnostics.push(diagnostic("invalid_nested_key", "networkRecovery must be an object", key));
508
+ break;
509
+ }
510
+ const nrRecord = value as Record<string, unknown>;
511
+ const nr: GoalNetworkRecoverySettingsLayer = {};
512
+ for (const [nKey, nValue] of Object.entries(nrRecord)) {
513
+ if (!ALLOWED_NETWORK_RECOVERY_KEYS.has(nKey)) {
514
+ diagnostics.push(diagnostic("unknown_key", `unknown networkRecovery key: ${nKey}`, `networkRecovery.${nKey}`));
515
+ continue;
516
+ }
517
+ if (nKey === "maxAttempts") {
518
+ const parsed = asNonNegativeInt(nValue);
519
+ if (parsed === undefined) diagnostics.push(diagnostic("invalid_value", "networkRecovery.maxAttempts must be an integer >= 0 (0 = unbounded)", `networkRecovery.${nKey}`));
520
+ else nr.maxAttempts = parsed;
521
+ } else {
522
+ const parsed = asNonNegativeInt(nValue);
523
+ if (parsed === undefined || parsed < 1_000) diagnostics.push(diagnostic("invalid_value", "networkRecovery.maxDelayMs must be an integer >= 1000", `networkRecovery.${nKey}`));
524
+ else nr.maxDelayMs = parsed;
525
+ }
526
+ }
527
+ if (Object.keys(nr).length > 0) layer.networkRecovery = nr;
528
+ break;
529
+ }
478
530
  default:
479
531
  diagnostics.push(diagnostic("unknown_key", `unknown settings key: ${key}`, key));
480
532
  break;
@@ -733,6 +785,20 @@ export function loadSettingsSnapshot(cwd: string, env: NodeJS.ProcessEnv = proce
733
785
  defaultValue: 0,
734
786
  envVar: "PI_GOAL_OBJECTIVE_MAX_CHARS",
735
787
  }));
788
+ const networkRecoveryMaxAttempts = track("networkRecovery.maxAttempts", resolveLeaf<number>({
789
+ envValue: envInt("PI_GOAL_NETWORK_RECOVERY_MAX_ATTEMPTS"),
790
+ projectValue: project.layer.networkRecovery?.maxAttempts,
791
+ globalValue: global.layer.networkRecovery?.maxAttempts,
792
+ defaultValue: 0,
793
+ envVar: "PI_GOAL_NETWORK_RECOVERY_MAX_ATTEMPTS",
794
+ }));
795
+ const networkRecoveryMaxDelayMs = track("networkRecovery.maxDelayMs", resolveLeaf<number>({
796
+ envValue: envInt("PI_GOAL_NETWORK_RECOVERY_MAX_DELAY_MS"),
797
+ projectValue: project.layer.networkRecovery?.maxDelayMs,
798
+ globalValue: global.layer.networkRecovery?.maxDelayMs,
799
+ defaultValue: DEFAULT_NETWORK_RECOVERY_MAX_DELAY_MS,
800
+ envVar: "PI_GOAL_NETWORK_RECOVERY_MAX_DELAY_MS",
801
+ }));
736
802
 
737
803
  const keybindings: GoalKeybindings = {
738
804
  dashboard: {
@@ -768,6 +834,10 @@ export function loadSettingsSnapshot(cwd: string, env: NodeJS.ProcessEnv = proce
768
834
  stallTimeoutMinutes,
769
835
  objectiveMaxChars,
770
836
  keybindings,
837
+ networkRecovery: {
838
+ maxAttempts: networkRecoveryMaxAttempts,
839
+ maxDelayMs: networkRecoveryMaxDelayMs,
840
+ },
771
841
  oracle: {
772
842
  enabled: oracleEnabled,
773
843
  ...(oracleProvider ? { provider: oracleProvider } : {}),
@@ -1076,6 +1146,13 @@ function buildPersistedLayer(settings: GoalSettings): Record<string, unknown> {
1076
1146
  if (settings.autoSelectSingleGoal !== undefined) persisted.autoSelectSingleGoal = settings.autoSelectSingleGoal;
1077
1147
  if (settings.auditorProjectResources !== undefined) persisted.auditorProjectResources = settings.auditorProjectResources;
1078
1148
  if (settings.hideUnfocusedBanner !== undefined) persisted.hideUnfocusedBanner = settings.hideUnfocusedBanner;
1149
+ if ((settings as { networkRecovery?: ResolvedGoalNetworkRecoverySettings }).networkRecovery) {
1150
+ const nr = (settings as { networkRecovery?: ResolvedGoalNetworkRecoverySettings }).networkRecovery!;
1151
+ const o: Record<string, unknown> = {};
1152
+ if (nr.maxAttempts !== undefined) o.maxAttempts = nr.maxAttempts;
1153
+ if (nr.maxDelayMs !== undefined) o.maxDelayMs = nr.maxDelayMs;
1154
+ if (Object.keys(o).length > 0) persisted.networkRecovery = o;
1155
+ }
1079
1156
  if ((settings as { oracle?: ResolvedGoalOracleSettings }).oracle) {
1080
1157
  const o: Record<string, unknown> = {};
1081
1158
  const so = (settings as { oracle?: ResolvedGoalOracleSettings }).oracle!;
@@ -1104,6 +1181,10 @@ export function envOverrideFor(key: keyof GoalSettings | "settingsFile", env: No
1104
1181
  if (key === "disableTasks" && env.PI_GOAL_DISABLE_TASKS !== undefined) return "PI_GOAL_DISABLE_TASKS";
1105
1182
  if (key === "disableContracts" && env.PI_GOAL_DISABLE_CONTRACTS !== undefined) return "PI_GOAL_DISABLE_CONTRACTS";
1106
1183
  if (key === "objectiveMaxChars" && env.PI_GOAL_OBJECTIVE_MAX_CHARS !== undefined) return "PI_GOAL_OBJECTIVE_MAX_CHARS";
1184
+ if (key === "networkRecovery") {
1185
+ if (env.PI_GOAL_NETWORK_RECOVERY_MAX_ATTEMPTS !== undefined) return "PI_GOAL_NETWORK_RECOVERY_MAX_ATTEMPTS";
1186
+ if (env.PI_GOAL_NETWORK_RECOVERY_MAX_DELAY_MS !== undefined) return "PI_GOAL_NETWORK_RECOVERY_MAX_DELAY_MS";
1187
+ }
1107
1188
  if (key === "settingsFile" && env[PI_GOAL_SETTINGS_FILE_ENV] !== undefined) return PI_GOAL_SETTINGS_FILE_ENV;
1108
1189
  return null;
1109
1190
  }
@@ -1128,6 +1209,8 @@ export function effectiveSettingsReport(cwd: string, env: NodeJS.ProcessEnv = pr
1128
1209
  { key: "hideUnfocusedBanner", label: "hide unfocused banner", format: () => String(snapshot.value.hideUnfocusedBanner) },
1129
1210
  { key: "stallTimeoutMinutes", label: "stall timeout (minutes)", format: () => String(snapshot.value.stallTimeoutMinutes) },
1130
1211
  { key: "objectiveMaxChars", label: "max objective length (0 = none)", format: () => String(snapshot.value.objectiveMaxChars) },
1212
+ { key: "networkRecovery", label: "network recovery attempts (0 = unbounded)", format: () => String(snapshot.value.networkRecovery?.maxAttempts ?? 0) },
1213
+ { key: "networkRecovery", label: "network recovery max delay (ms)", format: () => String(snapshot.value.networkRecovery?.maxDelayMs ?? DEFAULT_NETWORK_RECOVERY_MAX_DELAY_MS) },
1131
1214
  { key: "keybindings", label: "dashboard keybindings", format: () => `${snapshot.value.keybindings!.dashboard.toggleExpand}, ${snapshot.value.keybindings!.dashboard.scrollUp}, ${snapshot.value.keybindings!.dashboard.scrollDown}` },
1132
1215
  ];
1133
1216
  for (const row of rows) {
@@ -1,25 +1,55 @@
1
1
  /**
2
- * Bounded, goal-level recovery after Pi has exhausted its provider retries.
2
+ * Goal-level recovery after Pi has exhausted its provider retries.
3
3
  *
4
4
  * Pi owns the immediate request retries. These longer delays are deliberately
5
- * small in number and only apply to terminal `network_error` failures, so an
6
- * unavailable provider never becomes an unbounded auto-continue loop.
5
+ * slow and only apply to transient provider failures, so an unavailable
6
+ * provider never becomes a rapid auto-continue loop.
7
+ *
8
+ * Default policy is UNBOUNDED: recovery keeps retrying forever on an
9
+ * escalating delay ladder that plateaus at `maxDelayMs`. A bounded cap can
10
+ * be configured via layered settings (`networkRecovery.maxAttempts`, where
11
+ * 0/unset means unbounded).
7
12
  */
8
13
 
9
14
  export const NETWORK_ERROR_BACKOFF_DELAYS_MS = [5_000, 10_000, 20_000, 40_000, 80_000] as const;
10
15
 
16
+ /** Recovery policy: maxAttempts 0 = unbounded (default); delays plateau at maxDelayMs. */
17
+ export interface NetworkErrorRecoveryPolicy {
18
+ maxAttempts: number;
19
+ maxDelayMs: number;
20
+ }
21
+
22
+ export const DEFAULT_NETWORK_ERROR_RECOVERY_POLICY: NetworkErrorRecoveryPolicy = {
23
+ maxAttempts: 0,
24
+ maxDelayMs: NETWORK_ERROR_BACKOFF_DELAYS_MS[NETWORK_ERROR_BACKOFF_DELAYS_MS.length - 1]!,
25
+ };
26
+
11
27
  export interface NetworkErrorBackoffPlan {
12
28
  attempt: number;
29
+ /** 0 = unbounded. */
13
30
  maxAttempts: number;
14
31
  delayMs: number;
15
32
  }
16
33
 
17
- /** Return the recovery plan for a one-based attempt, or undefined after the cap. */
18
- export function networkErrorBackoffPlan(attempt: number): NetworkErrorBackoffPlan | undefined {
19
- if (!Number.isInteger(attempt) || attempt < 1 || attempt > NETWORK_ERROR_BACKOFF_DELAYS_MS.length) return undefined;
34
+ function rawLadderDelay(attempt: number): number {
35
+ const last = NETWORK_ERROR_BACKOFF_DELAYS_MS.length - 1;
36
+ return NETWORK_ERROR_BACKOFF_DELAYS_MS[Math.min(attempt - 1, last)]!;
37
+ }
38
+
39
+ /**
40
+ * Return the recovery plan for a one-based attempt under the given policy,
41
+ * or undefined once a configured bounded cap is exhausted. With the default
42
+ * unbounded policy this never returns undefined.
43
+ */
44
+ export function networkErrorBackoffPlan(
45
+ attempt: number,
46
+ policy: NetworkErrorRecoveryPolicy = DEFAULT_NETWORK_ERROR_RECOVERY_POLICY,
47
+ ): NetworkErrorBackoffPlan | undefined {
48
+ if (!Number.isInteger(attempt) || attempt < 1) return undefined;
49
+ if (policy.maxAttempts > 0 && attempt > policy.maxAttempts) return undefined;
20
50
  return {
21
51
  attempt,
22
- maxAttempts: NETWORK_ERROR_BACKOFF_DELAYS_MS.length,
23
- delayMs: NETWORK_ERROR_BACKOFF_DELAYS_MS[attempt - 1]!,
52
+ maxAttempts: policy.maxAttempts,
53
+ delayMs: Math.min(rawLadderDelay(attempt), policy.maxDelayMs),
24
54
  };
25
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goal-x",
3
- "version": "0.30.1",
3
+ "version": "0.30.3",
4
4
  "description": "Goal mode extension for pi: durable long-running objectives, five model tools, structured tasks, independent completion audit, Sisyphus mode, auto-continue, and a status overlay.",
5
5
  "license": "MIT",
6
6
  "author": "pi-goal-x contributors",