pi-quiver 3.3.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,11 @@ Published to npm as `pi-quiver` (`pi install npm:pi-quiver`). Pushing a
8
8
  via OIDC trusted publishing. The release helper at
9
9
  `.agents/skills/release/scripts/release.sh` cuts the tag; CI publishes.
10
10
 
11
+ ## v3.3.1 - 2026-07-19
12
+
13
+ - **`provider-stall-watchdog`: bounded multi-stall retry via `maxStallRetries`.** Each semantic stall converts to a retryable error until the budget is exhausted; previously only the first stall retried, so a longer provider outage forced manual resubmission (observed stuck streams recover in seconds on a fresh request). Default = layered `retry.maxRetries` (Pi default 3); explicit config wins; positive integer, fails closed. A successful assistant turn resets the counter, mirroring Pi's retry loop.
14
+ - **Watchdog notices moved to the main window.** The silence warning is now a `notify(..., "warning")` transcript line instead of a bottom status-line entry; the status-line integration is removed. Recovery notices report the attempt as `(used/max)`.
15
+
11
16
  ## v3.3.0 - 2026-07-17
12
17
 
13
18
  - **`provider-stall-watchdog`: opt-in semantic-silence recovery.** Policy D warns after configured silence, aborts the first semantic stall, and offers it once to Pi's existing retry loop; a second stall stops without another watchdog retry. OFF by default.
package/README.md CHANGED
@@ -67,7 +67,7 @@ A 300 KB changelog page never touches your context window - you get a preview an
67
67
  | `session-name.ts` | `/session-name` | Manual + opt-in automatic session naming, with Ghostty tab rename. OFF by default. |
68
68
  | `sword-header.ts` | `/builtin-header` | Themed ASCII startup header replacing pi's default logo. OFF by default. |
69
69
  | `fast-mode.ts` | `/fast` | Inject Anthropic fast-mode (`speed: "fast"` + `anthropic-beta: fast-mode-2026-02-01`) into every Claude Opus 4.8 request, any thinking level. `--fast` flag + `/fast [on\|off\|status]`. OFF by default. |
70
- | `provider-stall-watchdog.ts` | - | Opt-in semantic-silence watchdog for human interactive TUI runs. Warns after 2 minutes and recovers after 4 minutes; policy D offers the first stall once to Pi's retry loop, then stops a second stall. OFF by default. |
70
+ | `provider-stall-watchdog.ts` | - | Opt-in semantic-silence watchdog for human interactive TUI runs. Warns after 2 minutes and recovers after 4 minutes; policy D offers each stall to Pi's retry loop until the stall retry budget (`maxStallRetries`, default = `retry.maxRetries`) is exhausted. OFF by default. |
71
71
 
72
72
  Full routing rules, size-gate mechanics, and config: [doc/fetch.md](doc/fetch.md), [doc/doc-to-md.md](doc/doc-to-md.md).
73
73
 
@@ -162,12 +162,13 @@ Recommended explicit retry and watchdog settings:
162
162
  "providerStallWatchdog": {
163
163
  "enabled": true,
164
164
  "warningMs": 120000,
165
- "recoveryMs": 240000
165
+ "recoveryMs": 240000,
166
+ "maxStallRetries": 3
166
167
  }
167
168
  }
168
169
  ```
169
170
 
170
- `providerStallWatchdog` is OFF by default and runs only for confirmed human interactive TUI runs. JSON, RPC, print, and subagent runs are excluded by activation, not environment or session lineage. Verified with Pi 0.80.10: the first semantic stall is aborted and offered once to Pi retry; a second stall stops. Automatic continuation needs enabled Pi retry with remaining capacity. Disabled, exhausted, or incompatible retry degrades to manual resubmission. Pending steering or follow-ups return to the editor and are excluded from automatic continuation. Invalid merged watchdog config fails closed.
171
+ `providerStallWatchdog` is OFF by default and runs only for confirmed human interactive TUI runs. JSON, RPC, print, and subagent runs are excluded by activation, not environment or session lineage. Verified with Pi 0.80.10: each semantic stall is aborted and offered to Pi retry until `maxStallRetries` conversions are used; further stalls stop for manual resubmission. `maxStallRetries` defaults to the layered `retry.maxRetries` (Pi default 3); consecutive stall conversions consume Pi retry attempts without a success reset in between, so keep `maxStallRetries <= retry.maxRetries`. A successful assistant turn resets the stall counter (mirroring Pi's own retry counter). The silence warning and all watchdog notices render as main-window notifications, not the bottom status line. Automatic continuation needs enabled Pi retry with remaining capacity. Disabled, exhausted, or incompatible retry degrades to manual resubmission. Pending steering or follow-ups return to the editor and are excluded from automatic continuation. Invalid merged watchdog config fails closed.
171
172
 
172
173
  ## Development
173
174
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-quiver",
3
- "version": "3.3.0",
3
+ "version": "3.3.1",
4
4
  "description": "Personal pack of Pi coding-agent extensions: context-safe fetch, doc_to_md PDF/DOCX/PPTX-to-Markdown conversion, session naming, a themed ASCII startup header, Opus 4.8 fast mode, and a provider-stall watchdog.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { resolveConfig } from "./extension-config.ts";
2
+ import { readSettings, resolveConfig, settingsPaths } from "./extension-config.ts";
3
3
 
4
4
  export const MAX_TIMER_MS = 2_147_483_647;
5
5
  export const DEFAULT_CONFIG = {
@@ -12,6 +12,7 @@ export type WatchdogConfig = {
12
12
  enabled: boolean;
13
13
  warningMs: number;
14
14
  recoveryMs: number;
15
+ maxStallRetries: number;
15
16
  };
16
17
 
17
18
  export type WatchdogRuntime = {
@@ -25,6 +26,7 @@ export type ConfigCandidate = {
25
26
  enabled?: unknown;
26
27
  warningMs?: unknown;
27
28
  recoveryMs?: unknown;
29
+ maxStallRetries?: unknown;
28
30
  };
29
31
 
30
32
  export type ConfigValidation =
@@ -39,7 +41,7 @@ export function coerce(raw: unknown): ConfigCandidate | undefined {
39
41
 
40
42
  const source = raw as Record<string, unknown>;
41
43
  const candidate: ConfigCandidate = { blockIsObject: true };
42
- for (const key of ["enabled", "warningMs", "recoveryMs"] as const) {
44
+ for (const key of ["enabled", "warningMs", "recoveryMs", "maxStallRetries"] as const) {
43
45
  if (Object.hasOwn(source, key)) candidate[key] = source[key];
44
46
  }
45
47
  return candidate;
@@ -51,12 +53,14 @@ export function validateConfig(candidate: ConfigCandidate): ConfigValidation {
51
53
  if (!isTimerDelay(candidate.warningMs)) return { ok: false, error: "warningMs must be a positive timer delay" };
52
54
  if (!isTimerDelay(candidate.recoveryMs)) return { ok: false, error: "recoveryMs must be a positive timer delay" };
53
55
  if (candidate.warningMs >= candidate.recoveryMs) return { ok: false, error: "warningMs must be less than recoveryMs" };
56
+ if (!isPositiveInteger(candidate.maxStallRetries)) return { ok: false, error: "maxStallRetries must be a positive integer" };
54
57
  return {
55
58
  ok: true,
56
59
  config: {
57
60
  enabled: candidate.enabled,
58
61
  warningMs: candidate.warningMs,
59
62
  recoveryMs: candidate.recoveryMs,
63
+ maxStallRetries: candidate.maxStallRetries,
60
64
  },
61
65
  };
62
66
  }
@@ -65,8 +69,28 @@ function isTimerDelay(value: unknown): value is number {
65
69
  return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value > 0 && value <= MAX_TIMER_MS;
66
70
  }
67
71
 
72
+ function isPositiveInteger(value: unknown): value is number {
73
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
74
+ }
75
+
76
+ /** Pi's own resolution is `settings.retry?.maxRetries ?? 3` over the same layered settings.json files. */
77
+ export function resolveRetryMaxRetries(cwd: string): number {
78
+ let maxRetries = 3;
79
+ for (const path of settingsPaths(cwd)) {
80
+ const retry = readSettings(path)?.retry;
81
+ if (retry === null || typeof retry !== "object" || Array.isArray(retry)) continue;
82
+ const value = (retry as Record<string, unknown>).maxRetries;
83
+ if (isPositiveInteger(value)) maxRetries = value;
84
+ }
85
+ return maxRetries;
86
+ }
87
+
68
88
  export function resolveWatchdogConfig(cwd: string): ConfigValidation {
69
- return validateConfig(resolveConfig(cwd, "providerStallWatchdog", DEFAULT_CANDIDATE, coerce));
89
+ const candidate = resolveConfig(cwd, "providerStallWatchdog", DEFAULT_CANDIDATE, coerce);
90
+ if (candidate.blockIsObject === true && candidate.maxStallRetries === undefined) {
91
+ candidate.maxStallRetries = resolveRetryMaxRetries(cwd);
92
+ }
93
+ return validateConfig(candidate);
70
94
  }
71
95
 
72
96
  const defaultRuntime: WatchdogRuntime = {
@@ -75,8 +99,6 @@ const defaultRuntime: WatchdogRuntime = {
75
99
  clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
76
100
  };
77
101
 
78
- const STATUS_KEY = "providerStallWatchdog";
79
- const SECOND_STALL_NOTICE = "The retry also stopped making progress; aborting without another automatic retry. Submit the message again manually.";
80
102
  const DEGRADATION_NOTICE = "The stalled request was stopped, but Pi did not start an automatic retry. Retry may be disabled, exhausted, or incompatible; submit the message again to retry manually.";
81
103
 
82
104
  type Timer = { warning?: unknown; recovery?: unknown };
@@ -87,8 +109,12 @@ function formatElapsed(ms: number): string {
87
109
  return `${ms}ms`;
88
110
  }
89
111
 
90
- function warningStatus(config: WatchdogConfig): string {
91
- return `No model progress for ${formatElapsed(config.warningMs)}; aborting and asking Pi to retry once in ${formatElapsed(config.recoveryMs - config.warningMs)} (Esc aborts now)`;
112
+ function warningNotice(config: WatchdogConfig): string {
113
+ return `No model progress for ${formatElapsed(config.warningMs)}; aborting and asking Pi to retry in ${formatElapsed(config.recoveryMs - config.warningMs)} (Esc aborts now)`;
114
+ }
115
+
116
+ function exhaustedNotice(config: WatchdogConfig): string {
117
+ return `Stall retry budget (${config.maxStallRetries}) exhausted; aborting without another automatic retry. Submit the message again manually.`;
92
118
  }
93
119
 
94
120
  export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRuntime): (pi: ExtensionAPI) => void {
@@ -105,10 +131,10 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
105
131
  let deadlineEpoch = 0;
106
132
  let timers: Timer = {};
107
133
  let removeSignalListener: (() => void) | undefined;
108
- let ui: { setStatus(key: string, text: string | undefined): void; notify(text: string, type?: string): void } | undefined;
134
+ let ui: { notify(text: string, type?: string): void } | undefined;
109
135
  let watchdogAbortedGeneration: number | undefined;
110
136
  let timeoutConversionPending = false;
111
- let stallRetryConsumed = false;
137
+ let stallRetriesUsed = 0;
112
138
  let continuationStarted = false;
113
139
  let convertedTimeout = false;
114
140
 
@@ -117,12 +143,8 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
117
143
  if (timers.recovery !== undefined) runtime.clearTimeout(timers.recovery);
118
144
  timers = {};
119
145
  };
120
- const clearDeadlines = () => {
121
- clearTimers();
122
- ui?.setStatus(STATUS_KEY, undefined);
123
- };
124
146
  const clear = () => {
125
- clearDeadlines();
147
+ clearTimers();
126
148
  removeSignalListener?.();
127
149
  removeSignalListener = undefined;
128
150
  activeGeneration = undefined;
@@ -132,7 +154,7 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
132
154
  disarm();
133
155
  activeRun = false;
134
156
  pendingInteractive = false;
135
- stallRetryConsumed = false;
157
+ stallRetriesUsed = 0;
136
158
  continuationStarted = false;
137
159
  convertedTimeout = false;
138
160
  watchdogAbortedGeneration = undefined;
@@ -152,20 +174,20 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
152
174
  }
153
175
  if (kind === "warning" && !warned) {
154
176
  warned = true;
155
- ctx.ui?.setStatus(STATUS_KEY, warningStatus(config));
177
+ ctx.ui?.notify(warningNotice(config), "warning");
156
178
  }
157
179
  if (kind === "recovery") {
158
- clearDeadlines();
180
+ clearTimers();
159
181
  warned = false;
160
- if (stallRetryConsumed) {
161
- ui?.notify(SECOND_STALL_NOTICE);
182
+ if (stallRetriesUsed >= config.maxStallRetries) {
183
+ ui?.notify(exhaustedNotice(config));
162
184
  ctx.abort();
163
185
  return;
164
186
  }
165
187
  watchdogAbortedGeneration = capturedGeneration;
166
188
  timeoutConversionPending = true;
167
- stallRetryConsumed = true;
168
- ui?.notify(`No model progress for ${formatElapsed(elapsed)}; aborting now. Pi will retry once if retry is enabled and capacity remains. Pending follow-ups are returned to the editor.`);
189
+ stallRetriesUsed += 1;
190
+ ui?.notify(`No model progress for ${formatElapsed(elapsed)}; aborting now. Pi will retry (${stallRetriesUsed}/${config.maxStallRetries}) if retry is enabled and capacity remains. Pending follow-ups are returned to the editor.`);
169
191
  ctx.abort();
170
192
  }
171
193
  };
@@ -212,7 +234,6 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
212
234
  if (activeGeneration === undefined || !(update.type === "text_delta" || update.type === "thinking_delta" || update.type === "toolcall_delta") || update.delta.length === 0) return;
213
235
  lastSemanticAt = runtime.now();
214
236
  warned = false;
215
- ctx.ui.setStatus(STATUS_KEY, undefined);
216
237
  clearTimers();
217
238
  schedule(ctx);
218
239
  });
@@ -220,12 +241,14 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
220
241
  if (event.message.role !== "assistant") return;
221
242
  const matchesWatchdogAbort = event.message.stopReason === "aborted"
222
243
  && activeGeneration === watchdogAbortedGeneration
223
- && timeoutConversionPending
224
- && stallRetryConsumed;
244
+ && timeoutConversionPending;
225
245
  disarm();
246
+ // Mirror Pi's retry loop, which resets its attempt counter on any successful assistant turn.
247
+ if (event.message.stopReason !== "aborted" && event.message.stopReason !== "error") stallRetriesUsed = 0;
226
248
  if (!matchesWatchdogAbort || !config) return;
227
249
  timeoutConversionPending = false;
228
250
  convertedTimeout = true;
251
+ continuationStarted = false;
229
252
  return { message: { ...event.message, stopReason: "error", errorMessage: `Provider semantic timeout after ${config.recoveryMs} ms without progress` } };
230
253
  });
231
254
  pi.on("agent_end", () => disarm());