oira666_pi-limits-wait 0.6.3 → 0.6.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.
package/README.md CHANGED
@@ -27,9 +27,10 @@ Restart Pi or run `/reload` after installing.
27
27
  - For any other (unclassified) provider error — including an API `Internal server error` and HTTP `400`/`403` failures — waits and retries the same model. There is deliberately no permanent-HTTP-status allowlist. This retry-all behavior is enabled by default and uses a configurable retry limit and interval; it does not alter model-limit wait intervals.
28
28
  - Reports retry/fallback waits in chat notifications without adding persistent TUI status lines.
29
29
  - In non-interactive hosts (`rpc` and `json` modes) the extension never reports a planned retry as an error. While it is waiting to retry, it publishes `info`-level liveliness notifications (plus a `setStatus` entry) describing what it is doing, how long it will wait, and why. An `error`-level notification is emitted only when the extension stops retrying and surfaces the terminal failure. See [Non-interactive (RPC) reporting](#non-interactive-rpc-reporting).
30
- - Leaves terminal input to Pi, so built-in selectors remain usable during a wait. Choosing another model with `/model` immediately ends the pending wait and continues with the newly selected provider/model.
30
+ - During a wait, pressing Enter in an empty editor retries immediately. Typed commands, autocomplete, and model-picker navigation still work normally. Choosing another model with `/model` immediately ends the pending wait and tries that exact model, even if it was previously marked unavailable.
31
+ - Keeps a manually chosen model ahead of the configured priority list until it fails. After failure, normal retry/fallback rules apply and subsequent calls use the configured order again.
31
32
  - Optionally falls back to configured models when the current/default model is rate-limited. Every fallback to a different model—even on the same provider—deliberately discards request-owned credentials, headers, environment overrides, and provider-specific options so the target runtime resolves its own configuration.
32
- - Preserves Pi's `before_provider_request` / `after_provider_response` callback flow and sanitises only the exact Anthropic OAuth system-block shape generated by current Pi; Pi remains responsible for OAuth identity insertion.
33
+ - Preserves Pi's `before_provider_request` / `after_provider_response` callback flow. When Pi's exact Anthropic OAuth identity block is present, sanitises both the initial prompt and later system-message updates, repeating the Claude Code identity in textual updates. User/assistant messages and tool blocks stay untouched. Pi remains responsible for initial OAuth identity insertion and authentication; this does not repair invalid credentials.
33
34
 
34
35
  ### Unsupported interception limitation
35
36
 
@@ -235,10 +236,10 @@ Top-level settings keys:
235
236
 
236
237
  - `probe-interval-seconds` — optional. How often a limited/frozen higher-priority model is re-probed. Default `60`, `0` disables probing. `PI_LIMITS_WAIT_PROBE_INTERVAL` overrides it. Both sources accept only safe non-negative integers (numeric strings are also accepted); values above 2147483 seconds are capped. Invalid file values use the default, and invalid environment values use the file value or default.
237
238
 
238
- Fallback behavior — strict priority, highest first:
239
+ Fallback priority and manual selection:
239
240
 
240
- 1. When the default/user-selected model is listed, `fallback-models` is authoritative, top to bottom—even if that selected model is lower in the list. A selection **outside** the list stays first; only on its failure does the configured fallback list apply. Unrelated model calls and internal synthetic models are not rerouted.
241
- 2. Every LLM call restarts from the top of that order. The highest-priority model that is not known to be unavailable is used, so the first model runs whenever it can, and a lower-priority model is used **only** while every model above it is unavailable — and only for as long as that stays true.
241
+ 1. Normally, `fallback-models` sets priority from top to bottom. An unlisted startup model is tried before the list. Unrelated model calls and internal synthetic models are not rerouted.
242
+ 2. A manual model change overrides that priority, whether the chosen model is listed or not. Calls stay on that model without probing higher-priority models while it works. If it fails, the override ends and normal retry/fallback rules apply. After failover, the configured list controls priority again; recovery of the manually chosen model does not restore its override. Abort and context-overflow recovery do not cancel the override. Without an override, each LLM call starts at the top of the order and uses the highest-priority available model.
242
243
  3. If the active model becomes rate-limited, the extension immediately moves down to the next usable model in the order.
243
244
  4. As soon as a higher-priority model works again, the next call goes back to it and Pi's active model is switched back automatically. That happens in three ways:
244
245
  - the remembered limit for that model expires;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oira666_pi-limits-wait",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Extension for PI that allows to automatically wait for limits to reset and retry the request",
5
5
  "type": "module",
6
6
  "exports": {
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "repository": {
20
20
  "type": "git",
21
- "url": "https://github.com/gee666/pi-limits-wait.git"
21
+ "url": "git+https://github.com/gee666/pi-limits-wait.git"
22
22
  },
23
23
  "bugs": {
24
24
  "url": "https://github.com/gee666/pi-limits-wait/issues"
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "scripts": {
31
31
  "check": "tsc -p tsconfig.json --noEmit",
32
- "test": "tsx src/__tests__/test-extension.ts",
32
+ "test": "tsx src/__tests__/test-extension.ts && tsx src/__tests__/test-wait-ui.ts",
33
33
  "pack:check": "npm pack --dry-run",
34
34
  "prepublishOnly": "npm run check && npm test && npm run pack:check"
35
35
  },
package/src/constants.ts CHANGED
@@ -43,7 +43,7 @@ export const DEFAULT_LIVELINESS_INTERVAL_MS = 15 * 1_000; // 15 seconds
43
43
  export const LIVELINESS_JSON_STATUS_KEY = "oira666.pi-limits-wait.json";
44
44
  /** Payload schema version for LIVELINESS_JSON_STATUS_KEY. Bump only on a breaking change. */
45
45
  export const LIVELINESS_JSON_VERSION = 1;
46
- export const EXTENSION_VERSION = "0.6.2";
46
+ export const EXTENSION_VERSION = "0.6.5";
47
47
 
48
48
  export const SETTINGS_FILE_NAME = "limits-wait.json";
49
49
  export const FALLBACK_MODELS_KEY = "fallback-models";
package/src/index.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { isInternalSyntheticModel } from "./models.js";
2
+ import { isInternalSyntheticModel, modelKey } from "./models.js";
3
3
  import { sanitiseAnthropicPayloadSystem } from "./prompt.js";
4
4
  import { RetrySummaryCoordinator } from "./retry-summary.js";
5
5
  import { loadFallbackSettings } from "./settings.js";
6
- import { allWaitingDisabled, loadUnknownErrorRetrySettings } from "./ui.js";
6
+ import { allWaitingDisabled, installWaitRetryEditor, loadUnknownErrorRetrySettings } from "./ui.js";
7
7
  import { consumeExpectedModelSelection, state } from "./state.js";
8
8
  import { disableModelRuntimeInterception, installModelRuntimeInterception } from "./stream.js";
9
9
 
@@ -100,9 +100,9 @@ export type { FallbackModel } from "./types.js";
100
100
 
101
101
  export default function (pi: ExtensionAPI) {
102
102
  // Current pi-ai inserts the Claude Code identity as the first system block
103
- // for Anthropic OAuth. Sanitise only that exact payload shape; authentication,
104
- // identity insertion, Anthropic headers, and all other request composition
105
- // remain owned by ModelRuntime.
103
+ // for Anthropic OAuth. Use that exact shape to guard sanitisation of the
104
+ // initial prompt and later system updates, which repeat the identity.
105
+ // Authentication, initial identity insertion, and headers remain runtime-owned.
106
106
  pi.on("before_provider_request", (event) => sanitiseAnthropicPayloadSystem(event.payload));
107
107
 
108
108
  // Keep prompt sanitisation and the runtime's normal Anthropic header path,
@@ -125,11 +125,23 @@ export default function (pi: ExtensionAPI) {
125
125
  on(event: "cache_warming_decision", handler: () => { action: "stop" }): void;
126
126
  }).on("cache_warming_decision", () => ({ action: "stop" }));
127
127
 
128
+ pi.on("session_start", (event, ctx) => {
129
+ state.sharedCtx = ctx;
130
+ installWaitRetryEditor(ctx, event.reason);
131
+ });
132
+
128
133
  pi.on("model_select", (event, ctx) => {
129
134
  state.sharedCtx = ctx;
130
135
  if (consumeExpectedModelSelection(event.model)) return;
131
136
  if (isInternalSyntheticModel(event.model)) return;
132
137
  state.primaryModel = event.model;
138
+ state.manualModelOverride = event.source === "restore" ? undefined : event.model;
139
+ state.manualModelOverrideReleased = false;
140
+ // An explicit choice deserves a fresh attempt, even if previously blocked.
141
+ if (state.manualModelOverride) {
142
+ state.rateLimitMemory.delete(modelKey(event.model));
143
+ state.nonRetryableFailureMemory.delete(modelKey(event.model));
144
+ }
133
145
  state.primaryThinkingLevel = pi.getThinkingLevel();
134
146
  state.userModelSelectionGeneration++;
135
147
  // A user-selected model takes precedence over a pending retry. Wake every
package/src/models.ts CHANGED
@@ -38,14 +38,16 @@ export function configuredAttempt(model: Model<Api>): FallbackModel {
38
38
  }
39
39
 
40
40
  /**
41
- * Listed models are authoritative, top to bottom. An explicitly selected
42
- * model outside the list stays first; the configured list is used only if
43
- * that selection fails. Unrelated calls are excluded by isFallbackEligibleModel.
41
+ * A manual choice stays first until it fails. Otherwise use configured priority.
42
+ * An unlisted startup model stays first until an explicit selection has failed.
43
+ * Unrelated calls are excluded by isFallbackEligibleModel.
44
44
  */
45
45
  export function candidateOrder(current: Model<Api>): FallbackModel[] {
46
46
  const primary = getPrimaryModel(current);
47
47
  const listed = state.fallbackModels.some((entry) => modelKey(entry.model) === modelKey(primary));
48
- const order: FallbackModel[] = listed ? [] : [configuredAttempt(primary)];
48
+ const order: FallbackModel[] = state.manualModelOverride
49
+ ? [configuredAttempt(state.manualModelOverride)]
50
+ : listed || state.manualModelOverrideReleased ? [] : [configuredAttempt(primary)];
49
51
  const seen = new Set(order.map((entry) => modelKey(entry.model)));
50
52
  for (const entry of state.fallbackModels) {
51
53
  const key = modelKey(entry.model);
package/src/prompt.ts CHANGED
@@ -34,7 +34,8 @@ function isPiClaudeCodeIdentityBlock(block: unknown): block is SystemTextBlock {
34
34
  * current pi-ai runtime: the Claude Code identity is the first system block.
35
35
  * Looking only at that exact first block avoids treating a later user-authored
36
36
  * identity mention as proof of OAuth. The runtime remains solely responsible
37
- * for inserting the identity and selecting OAuth headers.
37
+ * for inserting the initial identity and selecting OAuth headers. Later system
38
+ * updates are sanitised too and repeat the identity alongside their instructions.
38
39
  */
39
40
  export function sanitiseAnthropicPayloadSystem(payload: unknown): unknown {
40
41
  if (!payload || typeof payload !== "object") return payload;
@@ -60,5 +61,50 @@ export function sanitiseAnthropicPayloadSystem(payload: unknown): unknown {
60
61
  }
61
62
  }
62
63
 
63
- return changed ? { ...payload, system: newSystem } : payload;
64
+ // Pi 0.86+ can send prompt updates as system messages in the transcript.
65
+ // Only transform their text; tool additions/removals and conversation content
66
+ // must remain unchanged. Keep the OAuth guard above for every transformation.
67
+ const messages = (payload as { messages?: unknown }).messages;
68
+ const newMessages = Array.isArray(messages) ? messages.map((message: unknown) => {
69
+ if (!message || typeof message !== "object") return message;
70
+ const candidate = message as { role?: unknown; content?: unknown };
71
+ if (candidate.role !== "system") return message;
72
+
73
+ if (typeof candidate.content === "string") {
74
+ const cleaned = sanitiseSystemPrompt(candidate.content);
75
+ const content = cleaned ? `${CLAUDE_CODE_IDENTITY}\n\n${cleaned}` : CLAUDE_CODE_IDENTITY;
76
+ if (content === candidate.content) return message;
77
+ changed = true;
78
+ return { ...message, content };
79
+ }
80
+ if (!Array.isArray(candidate.content)) return message;
81
+
82
+ let messageChanged = false;
83
+ let hasIdentity = false;
84
+ const content = candidate.content.map((block: unknown) => {
85
+ if (!block || typeof block !== "object") return block;
86
+ const textBlock = block as SystemTextBlock;
87
+ if (textBlock.type !== "text" || typeof textBlock.text !== "string") return block;
88
+ const cleaned = sanitiseSystemPrompt(textBlock.text);
89
+ const text = hasIdentity ? cleaned
90
+ : cleaned ? `${CLAUDE_CODE_IDENTITY}\n\n${cleaned}` : CLAUDE_CODE_IDENTITY;
91
+ hasIdentity = true;
92
+ if (text === textBlock.text) return block;
93
+ messageChanged = true;
94
+ return { ...textBlock, text };
95
+ }).filter((block: unknown) => {
96
+ if (!block || typeof block !== "object") return true;
97
+ const candidate = block as SystemTextBlock;
98
+ return candidate.type !== "text" || candidate.text !== "";
99
+ });
100
+ if (!messageChanged) return message;
101
+ changed = true;
102
+ return { ...message, content };
103
+ }) : undefined;
104
+
105
+ return changed ? {
106
+ ...payload,
107
+ system: newSystem,
108
+ ...(newMessages ? { messages: newMessages } : {}),
109
+ } : payload;
64
110
  }
package/src/state.ts CHANGED
@@ -21,6 +21,8 @@ export const state = {
21
21
  probeIntervalMs: DEFAULT_PROBE_INTERVAL_MS,
22
22
  primaryModel: undefined as Model<Api> | undefined,
23
23
  primaryThinkingLevel: undefined as ModelThinkingLevel | undefined,
24
+ manualModelOverride: undefined as Model<Api> | undefined,
25
+ manualModelOverrideReleased: false,
24
26
  expectedModelSelections: new Map<string, Set<symbol>>(),
25
27
  userModelSelectionGeneration: 0,
26
28
  activeWaitSkips: new Set<() => void>(),
@@ -62,6 +64,8 @@ export function resetRuntimeStateForTests(models: FallbackModel[], ctx?: Extensi
62
64
  state.sharedCtx = ctx;
63
65
  state.primaryModel = undefined;
64
66
  state.primaryThinkingLevel = undefined;
67
+ state.manualModelOverride = undefined;
68
+ state.manualModelOverrideReleased = false;
65
69
  state.rateLimitMemory.clear();
66
70
  state.nonRetryableFailureMemory.clear();
67
71
  state.expectedModelSelections.clear();
package/src/stream.ts CHANGED
@@ -64,6 +64,8 @@ export function __configureFallbackModelsForTests(models: FallbackModel[], ctx?:
64
64
  state.sharedCtx = ctx;
65
65
  state.primaryModel = undefined;
66
66
  state.primaryThinkingLevel = undefined;
67
+ state.manualModelOverride = undefined;
68
+ state.manualModelOverrideReleased = false;
67
69
  state.rateLimitMemory.clear();
68
70
  state.nonRetryableFailureMemory.clear();
69
71
  state.ambientStatusCleanup?.();
@@ -217,6 +219,15 @@ export function streamWithLimitsRetry(
217
219
  const earliestCandidateWakeup = (current: Model<Api>) => findCandidateWakeup(current, exclusions());
218
220
  let observedUserModelSelection = state.userModelSelectionGeneration;
219
221
  let forceAttemptAfterSkip = false;
222
+ const releaseManualOverride = () => {
223
+ // A stale failure must not revoke a newer choice, even of the same model.
224
+ if (observedUserModelSelection === state.userModelSelectionGeneration
225
+ && state.manualModelOverride
226
+ && modelKey(state.manualModelOverride) === modelKey(attempt.model)) {
227
+ state.manualModelOverride = undefined;
228
+ state.manualModelOverrideReleased = true;
229
+ }
230
+ };
220
231
 
221
232
  const prepareAttemptForPi = async (): Promise<boolean> => {
222
233
  const originalKey = modelKey(model);
@@ -279,9 +290,10 @@ export function streamWithLimitsRetry(
279
290
  observedUserModelSelection = state.userModelSelectionGeneration;
280
291
  const selected = state.primaryModel ?? state.sharedCtx?.model;
281
292
  if (selected) {
282
- // New selections supersede the old attempt. Listed selections still
283
- // obey configured priority; an out-of-list selection stays first.
284
- attempt = allowFallback ? initialAttempt(selected) ?? configuredAttempt(selected) : configuredAttempt(selected);
293
+ // Try the user's exact choice, not the first configured candidate.
294
+ attempt = configuredAttempt(selected);
295
+ forceAttemptAfterSkip = true;
296
+ nonRetryableAttempts.clear();
285
297
  triedModels.clear();
286
298
  lastExhaustedFailure = undefined;
287
299
  }
@@ -503,7 +515,10 @@ export function streamWithLimitsRetry(
503
515
  continue;
504
516
  }
505
517
 
506
- if (event.type === "error") retryPeriod?.recordReason(event.error.errorMessage ?? "Provider stream error");
518
+ if (event.type === "error") {
519
+ if (event.reason !== "aborted") releaseManualOverride();
520
+ retryPeriod?.recordReason(event.error.errorMessage ?? "Provider stream error");
521
+ }
507
522
  if (event.type === "done" || event.type === "error") {
508
523
  retryPeriod?.finalize();
509
524
  clearLivelinessStatus();
@@ -587,6 +602,10 @@ export function streamWithLimitsRetry(
587
602
  return;
588
603
  }
589
604
 
605
+ // A superseded attempt cannot freeze or limit the user's newer choice.
606
+ if (observedUserModelSelection !== state.userModelSelectionGeneration) continue;
607
+ releaseManualOverride();
608
+
590
609
  if (probe && !committed) {
591
610
  // A probe is exactly one request, regardless of HTTP/network/unknown
592
611
  // classification. Abort and context overflow have already terminated.
package/src/ui.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { CustomEditor, type ExtensionContext, type SessionStartEvent } from "@earendil-works/pi-coding-agent";
1
2
  import { randomUUID } from "node:crypto";
2
3
  import { readFile, rename, unlink } from "node:fs/promises";
3
4
  import {
@@ -35,6 +36,51 @@ import {
35
36
  } from "./telemetry.js";
36
37
  import type { RetryReason } from "./types.js";
37
38
 
39
+ /**
40
+ * Install at session_start, never when a wait starts or ends: replacing the
41
+ * editor also moves focus and would otherwise dismiss an active picker.
42
+ * Intercept submissions, not terminal keys, so autocomplete and other focused
43
+ * components get to handle Enter first. Preserve an existing editor factory.
44
+ */
45
+ export function installWaitRetryEditor(
46
+ ctx: ExtensionContext,
47
+ reason: SessionStartEvent["reason"] = "startup",
48
+ ): void {
49
+ if (ctx.mode !== "tui") return;
50
+ const previous = ctx.ui.getEditorComponent();
51
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => {
52
+ const editor = previous?.(tui, theme, keybindings) ?? new CustomEditor(tui, theme, keybindings);
53
+ // Pi resets custom editors before reload and only transfers their text.
54
+ // Unlike startup/session switching, reload does not populate history after
55
+ // session_start. Seed our new default editor from the active branch only
56
+ // on reload; existing custom editors remain responsible for their history.
57
+ if (!previous && reason === "reload") {
58
+ for (const entry of ctx.sessionManager.getBranch()) {
59
+ if (entry.type !== "message" || entry.message.role !== "user") continue;
60
+ const content = entry.message.content;
61
+ const text = typeof content === "string"
62
+ ? content
63
+ : content.filter((block) => block.type === "text").map((block) => block.text).join("");
64
+ if (text) editor.addToHistory?.(text);
65
+ }
66
+ }
67
+ let onSubmit = editor.onSubmit;
68
+ // Pi assigns its submit callback after the factory returns.
69
+ Object.defineProperty(editor, "onSubmit", {
70
+ configurable: true,
71
+ get: () => (text: string) => {
72
+ if (!text.trim() && state.activeWaitSkips.size > 0) {
73
+ for (const skip of [...state.activeWaitSkips]) skip();
74
+ return;
75
+ }
76
+ onSubmit?.call(editor, text);
77
+ },
78
+ set: (handler: typeof editor.onSubmit) => { onSubmit = handler; },
79
+ });
80
+ return editor;
81
+ });
82
+ }
83
+
38
84
  export function formatDuration(ms: number): string {
39
85
  const totalSecs = Math.max(0, Math.ceil(ms / 1_000));
40
86
  const hours = Math.floor(totalSecs / 3_600);
@@ -452,10 +498,9 @@ export function waitForRetry(
452
498
  return;
453
499
  }
454
500
 
455
- // Raw terminal listeners run before whichever component currently owns
456
- // focus. Consuming Enter here would steal confirmation from built-in
457
- // selectors such as /model. Model changes and the out-of-band control
458
- // channel call this skip function directly instead.
501
+ // The session editor's empty-submit handler, model changes, and the
502
+ // out-of-band control channel share this skip function. No raw input hook:
503
+ // it would steal Enter from whichever picker currently owns focus.
459
504
  state.activeWaitSkips.add(skipWait);
460
505
 
461
506
  signal?.addEventListener("abort", onAbort);