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 +5 -0
- package/README.md +4 -3
- package/package.json +1 -1
- package/provider-stall-watchdog.ts +47 -24
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
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
|
91
|
-
return `No model progress for ${formatElapsed(config.warningMs)}; aborting and asking Pi to retry
|
|
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: {
|
|
134
|
+
let ui: { notify(text: string, type?: string): void } | undefined;
|
|
109
135
|
let watchdogAbortedGeneration: number | undefined;
|
|
110
136
|
let timeoutConversionPending = false;
|
|
111
|
-
let
|
|
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
|
-
|
|
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
|
-
|
|
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?.
|
|
177
|
+
ctx.ui?.notify(warningNotice(config), "warning");
|
|
156
178
|
}
|
|
157
179
|
if (kind === "recovery") {
|
|
158
|
-
|
|
180
|
+
clearTimers();
|
|
159
181
|
warned = false;
|
|
160
|
-
if (
|
|
161
|
-
ui?.notify(
|
|
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
|
-
|
|
168
|
-
ui?.notify(`No model progress for ${formatElapsed(elapsed)}; aborting now. Pi will retry
|
|
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());
|