pi-quiver 6.3.0 → 6.4.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/CHANGELOG.md +8 -0
- package/README.md +13 -7
- package/extensions/fast-mode.ts +2 -2
- package/extensions/provider-stall-watchdog.ts +180 -21
- package/lib/extension-config.ts +1 -1
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,14 @@ 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
|
+
## v6.4.0 - 2026-09-28
|
|
12
|
+
|
|
13
|
+
- provider-stall-watchdog: per-model threshold overrides via a `models` map on `quiver.providerStallWatchdog` - glob keys like `lmstudio/*` override `firstEventMs`/`warningMs`/`recoveryMs` for matching models, so slow local servers get patience without raising the global defaults (#18).
|
|
14
|
+
|
|
15
|
+
## v6.3.1 - 2026-09-25
|
|
16
|
+
|
|
17
|
+
- `provider-stall-watchdog` recovers again on pi >= 0.86 ([#23](https://github.com/jjuraszek/pi-quiver/issues/23)): pi's session abort now fences the run so its retry loop never runs after a watchdog abort; the watchdog omits the aborted attempt from the model's context at `turn_end` and re-drives the request itself via a hidden custom message after pi's backoff, honoring pi's `retry.enabled` / `retry.baseDelayMs` / `retry.maxAgentDelayMs` live and the existing `maxStallRetries` cap. Print/json await the backoff; TUI/RPC show `Retrying (n/m) in Ns... (Esc to cancel)` in the status bar on the timer path. In TUI, Esc, a new prompt, a tree switch, or compaction cancels the pending retry; RPC cancels on a new prompt, not Esc. Behavior guide: `doc/provider-stall-watchdog.md`. Dev dependencies on `@earendil-works/*` move to `^0.87.1`.
|
|
18
|
+
|
|
11
19
|
## v6.3.0 - 2026-09-16
|
|
12
20
|
|
|
13
21
|
- `session-name` Herdr sink claims any numeric tab label (optionally behind one `* `) at any position instead of only the label equal to the tab's live position, so tabs reordered before the first auto-name - or after a `/new` restore - get named; the claim resets at every `session_start` (`/new`, resume, fork behave like a fresh process) and a tab that reverts to a bare number is re-claimed on the next turn. The extension never writes a digits-only label (`1234` -> `#1234`) and the naming prompt asks for `PR 1234` / `issue 123` / `ticket ABC-123` over a bare ID.
|
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@ A 300 KB changelog page never touches your context window - you get a preview an
|
|
|
69
69
|
| `extensions/session-name.ts` | `/session-name` | Manual + opt-in automatic session naming, naming rules and deny list, long-session revisits, and Ghostty/Herdr tab rename. OFF by default. |
|
|
70
70
|
| `extensions/sword-header.ts` | `/builtin-header` | Themed ASCII startup header replacing pi's default logo. OFF by default. |
|
|
71
71
|
| `extensions/fast-mode.ts` | `/fast` | Inject Anthropic fast-mode (`speed: "fast"` + `anthropic-beta: fast-mode-2026-02-01`) into every Claude Opus 4.8 / Opus 5 request, any thinking level. `--fast` flag + `/fast [on\|off\|status]`. OFF by default. |
|
|
72
|
-
| `extensions/provider-stall-watchdog.ts` | - | Opt-in provider-stall recovery, in two tiers: a pre-first-event deadline (`firstEventMs`, 20s) on every provider request in every mode, and the mid-stream pair (warn at 2 min, recover at 4 min) in TUI runs only.
|
|
72
|
+
| `extensions/provider-stall-watchdog.ts` | - | Opt-in provider-stall recovery, in two tiers: a pre-first-event deadline (`firstEventMs`, 20s) on every provider request in every mode, and the mid-stream pair (warn at 2 min, recover at 4 min) in TUI runs only. Each stall is aborted; while pi's `retry.enabled` is true and the stall retry budget (`maxStallRetries`, default = `retry.maxRetries`) remains, its aborted attempt is hidden from the model and re-driven by the watchdog after pi's backoff; behavior guide: [doc/provider-stall-watchdog.md](doc/provider-stall-watchdog.md). OFF by default. |
|
|
73
73
|
| `extensions/slack.ts` | `slack_search`, `slack_thread`, `slack_post`, `slack_update`, `slack_delete`, `slack_pin`, `slack_upload`, `slack_cache_refresh` | Context-safe Slack search/threads/posting with dual `user`/`bot` token identities, Block Kit flattening and optional raw JSON output for threads, a workspace-keyed channel/user name->ID cache, DM targets by `@name` / user ID, fetch-style output size gating, and a transactional headline+detail-thread announce protocol with a documented recovery path. OFF by default. Behavior lives in `lib/slack-core.ts` and `lib/slack-cache.ts`. |
|
|
74
74
|
|
|
75
75
|
Full routing rules, size-gate mechanics, and config: [doc/fetch.md](doc/fetch.md), [doc/doc-to-md.md](doc/doc-to-md.md), [doc/slack.md](doc/slack.md).
|
|
@@ -186,7 +186,7 @@ a new setting is registered there or it warns as unknown.
|
|
|
186
186
|
```text
|
|
187
187
|
Warning: pi-quiver settings (/Users/x/.pi/agent/settings.json): unknown or misplaced keys - unknown ones fall back to defaults
|
|
188
188
|
"providerStallWatchdog" at top level - move under "quiver"
|
|
189
|
-
"quiver.providerStallWatchdog.timeoutMs" - unknown; accepted: enabled, firstEventMs, warningMs, recoveryMs, maxStallRetries
|
|
189
|
+
"quiver.providerStallWatchdog.timeoutMs" - unknown; accepted: enabled, firstEventMs, warningMs, recoveryMs, maxStallRetries, models
|
|
190
190
|
```
|
|
191
191
|
|
|
192
192
|
Worked mixed-shape example: global `settings.json` has flat
|
|
@@ -218,7 +218,10 @@ not a pi-quiver setting and is never nested):
|
|
|
218
218
|
"firstEventMs": 20000,
|
|
219
219
|
"warningMs": 120000,
|
|
220
220
|
"recoveryMs": 240000,
|
|
221
|
-
"maxStallRetries": 3
|
|
221
|
+
"maxStallRetries": 3,
|
|
222
|
+
"models": {
|
|
223
|
+
"lmstudio/*": { "firstEventMs": 600000, "recoveryMs": 600000 }
|
|
224
|
+
}
|
|
222
225
|
}
|
|
223
226
|
},
|
|
224
227
|
"retry": {
|
|
@@ -236,21 +239,24 @@ not a pi-quiver setting and is never nested):
|
|
|
236
239
|
| `warningMs` | `120000` | mid-stream, `ctx.mode === "tui"` only | Silence since the last non-empty text/thinking/toolcall delta; notifies. |
|
|
237
240
|
| `recoveryMs` | `240000` | mid-stream, `ctx.mode === "tui"` only | Same clock; aborts and converts. Must be `> warningMs`. |
|
|
238
241
|
| `maxStallRetries` | layered `retry.maxRetries`, else `3` | shared by both tiers | Watchdog aborts that may convert to a retryable error before stopping. |
|
|
242
|
+
| `models` | `{}` | per-model overrides of the three thresholds | Glob keys match `provider/model` (case-insensitive, `*` matches any run of characters, first match wins); each entry may override `firstEventMs`, `warningMs`, and/or `recoveryMs`. |
|
|
239
243
|
|
|
240
244
|
`providerStallWatchdog` is OFF by default. Once enabled it arms in two tiers per provider request:
|
|
241
245
|
|
|
242
|
-
- **Pre-first-event (`firstEventMs`).** Armed at every provider request, in every mode and from every origin - including extension-triggered turns that never emit `before_agent_start` - and cleared by the first assistant `message_start`. On expiry the request is aborted and, budget permitting, converted to a retryable error, so an unresponsive request recovers in ~22s (20s detection +
|
|
246
|
+
- **Pre-first-event (`firstEventMs`).** Armed at every provider request, in every mode and from every origin - including extension-triggered turns that never emit `before_agent_start` - and cleared by the first assistant `message_start`. On expiry the request is aborted and, budget permitting, converted to a retryable error and re-driven after pi's backoff when `retry.enabled` is true, so an unresponsive request recovers in ~22s (20s detection + 2s backoff) instead of the ~240s it took when only the mid-stream tier existed.
|
|
243
247
|
- **Mid-stream (`warningMs` / `recoveryMs`).** Armed from the first assistant `message_start` onward, and only when `ctx.mode === "tui"`. Aborting mid-generation discards billed output tokens and an unattended run has nobody to read the warning, so headless mid-stream silence deliberately falls through to the transport timeout instead.
|
|
244
248
|
|
|
245
|
-
|
|
249
|
+
`models` retunes the three thresholds per model. A request's effective thresholds are the base knobs overlaid with the first entry whose glob matches its `provider/model` label - `"lmstudio/*"` covers a whole local server, `"openai/gpt-5.4"` one remote model. An override that would leave the merged `warningMs >= recoveryMs` is invalid and fails closed at startup like any other invalid watchdog config.
|
|
250
|
+
|
|
251
|
+
**Raise `firstEventMs` if your provider is legitimately slow to first event** - per model via `models` when only one provider is slow. Queueing gateways, throttled endpoints, and busy single-slot local model servers can hold the connection for well over 20s before their first stream event; every false abort re-uploads the whole context and spends one stall retry.
|
|
246
252
|
|
|
247
253
|
**Leave pi's own `httpIdleTimeoutMs` (default `300000`) at its default.** It is the transport backstop, and a single value drives undici's `headersTimeout` *and* `bodyTimeout` - lowering it to get fast pre-stream failure also truncates legitimate mid-stream gaps. `firstEventMs` is the knob for pre-stream silence.
|
|
248
254
|
|
|
249
|
-
Verified with Pi 0.
|
|
255
|
+
Verified with Pi 0.87.1: each eligible stall is aborted, its message omitted from the model's context, and the request re-driven by the watchdog after pi's backoff (`retry.baseDelayMs`, capped at `retry.maxAgentDelayMs`) until `maxStallRetries` conversions are used; further stalls stop for manual resubmission. Both tiers draw on that one budget. `maxStallRetries` defaults to the layered `retry.maxRetries` (Pi default 3, an explicit `0` honoured); `0` is valid and means "detect and stop, never auto-retry". A successful assistant turn resets the stall counter (mirroring Pi's own retry counter). pi's `retry.enabled: false` disables the re-drive and degrades to manual resubmission without omitting the aborted message. Pending steering or follow-ups return to the editor and are excluded from automatic continuation. Invalid merged watchdog config fails closed. Print/json await pi's backoff before re-driving; TUI/RPC show a timer-path countdown that a new prompt cancels. Why the watchdog re-drives instead of pi, the hidden re-drive message, and the wait behavior: [doc/provider-stall-watchdog.md](doc/provider-stall-watchdog.md).
|
|
250
256
|
|
|
251
257
|
Operational notes:
|
|
252
258
|
|
|
253
|
-
- **Settings are read once per session,** on the first provider request. Editing `settings.json` mid-session changes nothing until you restart the session - that includes repairing an invalid block that already disabled the extension.
|
|
259
|
+
- **Settings are read once per session,** on the first provider request. Editing watchdog settings in `settings.json` mid-session changes nothing until you restart the session - that includes repairing an invalid block that already disabled the extension. The exception is pi's `retry.enabled`, `retry.baseDelayMs`, and `retry.maxAgentDelayMs`, which the re-drive reads live at each stall.
|
|
254
260
|
- **A watchdog abort that the provider ignores escalates after a fixed 10s.** Any post-abort stream event re-arms that deadline (bytes prove only that the connection was alive at that instant), so a stream that emits a straggler and then wedges still escalates 10s after its last event. This reduces the hang; it cannot force the provider to stop, and undici's timeouts remain the final backstop.
|
|
255
261
|
- **Headless runs report on stderr.** In `print`/`json` mode pi binds a no-op UI, so watchdog notices go out via `console.warn`. Nothing is ever written to stdout, which `json` mode uses for its protocol. In TUI and RPC the notices render as main-window notifications, not the bottom status line.
|
|
256
262
|
|
package/extensions/fast-mode.ts
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
31
|
-
import type { Context, Model, StreamOptions, Usage } from "@earendil-works/pi-ai";
|
|
31
|
+
import type { Context, Model, StreamOptions, TranscriptContext, Usage } from "@earendil-works/pi-ai";
|
|
32
32
|
import { resolveConfig } from "../lib/extension-config.ts";
|
|
33
33
|
|
|
34
34
|
export const FAST_MODE_BETA = "fast-mode-2026-02-01";
|
|
@@ -163,7 +163,7 @@ export async function probePiBetaHeader(
|
|
|
163
163
|
};
|
|
164
164
|
try {
|
|
165
165
|
const { anthropicMessagesApi } = await import("@earendil-works/pi-ai/compat");
|
|
166
|
-
const stream = anthropicMessagesApi().stream(model, PROBE_CONTEXT, options);
|
|
166
|
+
const stream = anthropicMessagesApi().stream(model, PROBE_CONTEXT as TranscriptContext, options);
|
|
167
167
|
await stream.result().catch(() => {});
|
|
168
168
|
} catch {
|
|
169
169
|
return captured;
|
|
@@ -7,14 +7,20 @@ export const DEFAULT_CONFIG = {
|
|
|
7
7
|
firstEventMs: 20_000,
|
|
8
8
|
warningMs: 120_000,
|
|
9
9
|
recoveryMs: 240_000,
|
|
10
|
+
models: {},
|
|
10
11
|
} as const;
|
|
11
12
|
|
|
12
|
-
export type
|
|
13
|
-
enabled: boolean;
|
|
13
|
+
export type WatchdogThresholds = {
|
|
14
14
|
firstEventMs: number;
|
|
15
15
|
warningMs: number;
|
|
16
16
|
recoveryMs: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type WatchdogConfig = WatchdogThresholds & {
|
|
20
|
+
enabled: boolean;
|
|
17
21
|
maxStallRetries: number;
|
|
22
|
+
/** Per-model threshold overrides keyed by glob; see thresholdsFor. */
|
|
23
|
+
models: Record<string, Partial<WatchdogThresholds>>;
|
|
18
24
|
};
|
|
19
25
|
|
|
20
26
|
export type WatchdogRuntime = {
|
|
@@ -30,6 +36,7 @@ export type ConfigCandidate = {
|
|
|
30
36
|
warningMs?: unknown;
|
|
31
37
|
recoveryMs?: unknown;
|
|
32
38
|
maxStallRetries?: unknown;
|
|
39
|
+
models?: unknown;
|
|
33
40
|
};
|
|
34
41
|
|
|
35
42
|
export type ConfigValidation =
|
|
@@ -45,12 +52,17 @@ export function coerce(raw: unknown): ConfigCandidate | undefined {
|
|
|
45
52
|
|
|
46
53
|
const source = raw as Record<string, unknown>;
|
|
47
54
|
const candidate: ConfigCandidate = { blockIsObject: true };
|
|
48
|
-
for (const key of ["enabled", "firstEventMs", "warningMs", "recoveryMs", "maxStallRetries"] as const) {
|
|
55
|
+
for (const key of ["enabled", "firstEventMs", "warningMs", "recoveryMs", "maxStallRetries", "models"] as const) {
|
|
49
56
|
if (Object.hasOwn(source, key)) candidate[key] = source[key];
|
|
50
57
|
}
|
|
51
58
|
return candidate;
|
|
52
59
|
}
|
|
53
60
|
|
|
61
|
+
const THRESHOLD_KEYS = ["firstEventMs", "warningMs", "recoveryMs"] as const;
|
|
62
|
+
|
|
63
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
64
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
65
|
+
|
|
54
66
|
export function validateConfig(candidate: ConfigCandidate): ConfigValidation {
|
|
55
67
|
if (candidate.blockIsObject !== true) return { ok: false, error: "providerStallWatchdog must be an object" };
|
|
56
68
|
if (typeof candidate.enabled !== "boolean") return { ok: false, error: "enabled must be a boolean" };
|
|
@@ -59,6 +71,21 @@ export function validateConfig(candidate: ConfigCandidate): ConfigValidation {
|
|
|
59
71
|
if (!isTimerDelay(candidate.recoveryMs)) return { ok: false, error: "recoveryMs must be a positive timer delay" };
|
|
60
72
|
if (candidate.warningMs >= candidate.recoveryMs) return { ok: false, error: "warningMs must be less than recoveryMs" };
|
|
61
73
|
if (!isNonNegativeInteger(candidate.maxStallRetries)) return { ok: false, error: "maxStallRetries must be a non-negative integer" };
|
|
74
|
+
if (!isRecord(candidate.models)) return { ok: false, error: "models must be an object" };
|
|
75
|
+
for (const [pattern, override] of Object.entries(candidate.models)) {
|
|
76
|
+
if (!isRecord(override)) return { ok: false, error: `models["${pattern}"] must be an object` };
|
|
77
|
+
for (const key of Object.keys(override)) {
|
|
78
|
+
if (!(THRESHOLD_KEYS as readonly string[]).includes(key)) {
|
|
79
|
+
return { ok: false, error: `models["${pattern}"] has unknown key "${key}"; accepted: ${THRESHOLD_KEYS.join(", ")}` };
|
|
80
|
+
}
|
|
81
|
+
if (!isTimerDelay(override[key])) return { ok: false, error: `models["${pattern}"].${key} must be a positive timer delay` };
|
|
82
|
+
}
|
|
83
|
+
const mergedWarning = (override.warningMs ?? candidate.warningMs) as number;
|
|
84
|
+
const mergedRecovery = (override.recoveryMs ?? candidate.recoveryMs) as number;
|
|
85
|
+
if (mergedWarning >= mergedRecovery) {
|
|
86
|
+
return { ok: false, error: `models["${pattern}"] leaves warningMs (${mergedWarning}) >= recoveryMs (${mergedRecovery})` };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
62
89
|
return {
|
|
63
90
|
ok: true,
|
|
64
91
|
config: {
|
|
@@ -67,6 +94,7 @@ export function validateConfig(candidate: ConfigCandidate): ConfigValidation {
|
|
|
67
94
|
warningMs: candidate.warningMs,
|
|
68
95
|
recoveryMs: candidate.recoveryMs,
|
|
69
96
|
maxStallRetries: candidate.maxStallRetries,
|
|
97
|
+
models: candidate.models as Record<string, Partial<WatchdogThresholds>>,
|
|
70
98
|
},
|
|
71
99
|
};
|
|
72
100
|
}
|
|
@@ -91,6 +119,27 @@ export function resolveRetryMaxRetries(cwd: string): number {
|
|
|
91
119
|
return maxRetries;
|
|
92
120
|
}
|
|
93
121
|
|
|
122
|
+
export type RetrySettings = { enabled: boolean; baseDelayMs: number; maxAgentDelayMs: number };
|
|
123
|
+
|
|
124
|
+
export function resolveRetrySettings(cwd: string): RetrySettings {
|
|
125
|
+
const settings: RetrySettings = { enabled: true, baseDelayMs: 2_000, maxAgentDelayMs: 60_000 };
|
|
126
|
+
for (const path of settingsPaths(cwd)) {
|
|
127
|
+
const retry = readSettings(path)?.retry;
|
|
128
|
+
if (retry === null || typeof retry !== "object" || Array.isArray(retry)) continue;
|
|
129
|
+
const source = retry as Record<string, unknown>;
|
|
130
|
+
if (typeof source.enabled === "boolean") settings.enabled = source.enabled;
|
|
131
|
+
if (isNonNegativeInteger(source.baseDelayMs)) settings.baseDelayMs = source.baseDelayMs;
|
|
132
|
+
if (isNonNegativeInteger(source.maxAgentDelayMs)) settings.maxAgentDelayMs = source.maxAgentDelayMs;
|
|
133
|
+
}
|
|
134
|
+
return settings;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function redriveDelayMs(settings: Pick<RetrySettings, "baseDelayMs" | "maxAgentDelayMs">, attempt: number): number {
|
|
138
|
+
const delay = settings.baseDelayMs * 2 ** Math.max(0, attempt - 1);
|
|
139
|
+
const safeDelay = Number.isSafeInteger(delay) ? delay : Number.MAX_SAFE_INTEGER;
|
|
140
|
+
return Math.min(safeDelay, settings.maxAgentDelayMs, MAX_TIMER_MS);
|
|
141
|
+
}
|
|
142
|
+
|
|
94
143
|
export function resolveWatchdogConfig(cwd: string, warn?: (msg: string) => void): ConfigValidation {
|
|
95
144
|
const candidate = resolveConfig(cwd, "providerStallWatchdog", DEFAULT_CANDIDATE, coerce, warn);
|
|
96
145
|
if (candidate.blockIsObject === true && candidate.maxStallRetries === undefined) {
|
|
@@ -105,6 +154,9 @@ const defaultRuntime: WatchdogRuntime = {
|
|
|
105
154
|
clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
106
155
|
};
|
|
107
156
|
|
|
157
|
+
const REDRIVE_TEXT = "The previous provider request stalled before completing and was retried automatically. Continue.";
|
|
158
|
+
const RETRY_CANCELLED_NOTICE = "Automatic retry cancelled; submit the message again to retry manually.";
|
|
159
|
+
const STATUS_KEY = "providerStallWatchdog";
|
|
108
160
|
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.";
|
|
109
161
|
// Reduces, but cannot eliminate, the hang when an aborted provider operation never terminates;
|
|
110
162
|
// undici's headersTimeout/bodyTimeout stay the backstop past this point.
|
|
@@ -120,20 +172,41 @@ function formatElapsed(ms: number): string {
|
|
|
120
172
|
|
|
121
173
|
const ABORT_STUCK_NOTICE = `The stalled request did not stop within ${formatElapsed(ABORT_GRACE_MS)} of being aborted; the provider connection is unresponsive. No automatic retry will run - the turn will not end until the HTTP idle timeout expires.`;
|
|
122
174
|
|
|
123
|
-
function warningNotice(
|
|
124
|
-
return `No model progress for ${formatElapsed(
|
|
175
|
+
function warningNotice(thresholds: WatchdogThresholds): string {
|
|
176
|
+
return `No model progress for ${formatElapsed(thresholds.warningMs)}; aborting and asking Pi to retry in ${formatElapsed(thresholds.recoveryMs - thresholds.warningMs)} (Esc aborts now)`;
|
|
125
177
|
}
|
|
126
178
|
|
|
127
179
|
function exhaustedNotice(config: WatchdogConfig): string {
|
|
128
180
|
return `Stall retry budget (${config.maxStallRetries}) exhausted; aborting without another automatic retry. Submit the message again manually.`;
|
|
129
181
|
}
|
|
130
182
|
|
|
131
|
-
function firstEventRetryNotice(
|
|
132
|
-
return `Provider sent no response for ${formatElapsed(
|
|
183
|
+
function firstEventRetryNotice(thresholds: WatchdogThresholds): string {
|
|
184
|
+
return `Provider sent no response for ${formatElapsed(thresholds.firstEventMs)}; stopping and retrying the request.`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function firstEventExhaustedNotice(thresholds: WatchdogThresholds): string {
|
|
188
|
+
return `Provider sent no response for ${formatElapsed(thresholds.firstEventMs)} and the stall-retry budget is spent; the request was stopped.`;
|
|
133
189
|
}
|
|
134
190
|
|
|
135
|
-
|
|
136
|
-
|
|
191
|
+
/** A glob where `*` matches any run of characters; matching is case-insensitive. */
|
|
192
|
+
function modelPattern(pattern: string): RegExp {
|
|
193
|
+
const source = pattern.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
|
|
194
|
+
return new RegExp(`^${source}$`, "i");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Effective thresholds for one request: the base knobs overlaid with the
|
|
199
|
+
* first `models` entry whose glob matches the request's `provider/model`
|
|
200
|
+
* label. First match wins; later entries for the same model are ignored.
|
|
201
|
+
*/
|
|
202
|
+
export function thresholdsFor(config: WatchdogConfig, model: { provider: string; id: string } | undefined): WatchdogThresholds {
|
|
203
|
+
const base: WatchdogThresholds = { firstEventMs: config.firstEventMs, warningMs: config.warningMs, recoveryMs: config.recoveryMs };
|
|
204
|
+
if (model === undefined) return base;
|
|
205
|
+
const label = `${model.provider}/${model.id}`;
|
|
206
|
+
for (const [pattern, override] of Object.entries(config.models)) {
|
|
207
|
+
if (modelPattern(pattern).test(label)) return { ...base, ...override };
|
|
208
|
+
}
|
|
209
|
+
return base;
|
|
137
210
|
}
|
|
138
211
|
|
|
139
212
|
export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRuntime): (pi: ExtensionAPI) => void {
|
|
@@ -147,6 +220,7 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
|
|
|
147
220
|
let config: WatchdogConfig | undefined;
|
|
148
221
|
let generation = 0;
|
|
149
222
|
let activeGeneration: number | undefined;
|
|
223
|
+
let activeModel: { provider: string; id: string } | undefined;
|
|
150
224
|
let lastSemanticAt = 0;
|
|
151
225
|
let warned = false;
|
|
152
226
|
let deadlineEpoch = 0;
|
|
@@ -157,6 +231,16 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
|
|
|
157
231
|
let stallRetriesUsed = 0;
|
|
158
232
|
let continuationStarted = false;
|
|
159
233
|
let convertedTimeout = false;
|
|
234
|
+
let redrivePending = false;
|
|
235
|
+
let redriveResolve: ((fired: boolean) => void) | undefined;
|
|
236
|
+
let exhaustedAbortGeneration: number | undefined;
|
|
237
|
+
let redriveEligible = false;
|
|
238
|
+
let redriveDelay = 0;
|
|
239
|
+
let redriveTimer: unknown;
|
|
240
|
+
let statusTimer: unknown;
|
|
241
|
+
let redriveInFlight = false;
|
|
242
|
+
let removeTerminalInput: (() => void) | undefined;
|
|
243
|
+
let statusUI: { setStatus(key: string, text: string | undefined): void } | undefined;
|
|
160
244
|
|
|
161
245
|
const clearTimers = () => {
|
|
162
246
|
for (const key of ["firstEvent", "warning", "recovery", "abortGuard"] as const) {
|
|
@@ -175,7 +259,24 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
|
|
|
175
259
|
activeGeneration = undefined;
|
|
176
260
|
};
|
|
177
261
|
const disarm = () => { clear(); warned = false; };
|
|
262
|
+
const cancelRedrive = () => {
|
|
263
|
+
if (redriveTimer !== undefined) runtime.clearTimeout(redriveTimer);
|
|
264
|
+
redriveTimer = undefined;
|
|
265
|
+
redriveResolve?.(false);
|
|
266
|
+
redriveResolve = undefined;
|
|
267
|
+
if (statusTimer !== undefined) runtime.clearTimeout(statusTimer);
|
|
268
|
+
statusTimer = undefined;
|
|
269
|
+
statusUI?.setStatus(STATUS_KEY, undefined);
|
|
270
|
+
statusUI = undefined;
|
|
271
|
+
removeTerminalInput?.();
|
|
272
|
+
removeTerminalInput = undefined;
|
|
273
|
+
};
|
|
178
274
|
const resetRunState = () => {
|
|
275
|
+
cancelRedrive();
|
|
276
|
+
redrivePending = false;
|
|
277
|
+
exhaustedAbortGeneration = undefined;
|
|
278
|
+
redriveEligible = false;
|
|
279
|
+
redriveInFlight = false;
|
|
179
280
|
disarm();
|
|
180
281
|
activeRun = false;
|
|
181
282
|
stallRetriesUsed = 0;
|
|
@@ -217,6 +318,7 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
|
|
|
217
318
|
// the generation check makes the callback a no-op if that teardown disarmed the watchdog.
|
|
218
319
|
armAbortGuard(capturedGeneration);
|
|
219
320
|
if (stallRetriesUsed >= config.maxStallRetries) {
|
|
321
|
+
exhaustedAbortGeneration = capturedGeneration;
|
|
220
322
|
announce(notices.exhausted());
|
|
221
323
|
ctx.abort();
|
|
222
324
|
return;
|
|
@@ -229,9 +331,10 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
|
|
|
229
331
|
const armFirstEvent = (ctx: { abort(): void }) => {
|
|
230
332
|
if (activeGeneration === undefined || !config) return;
|
|
231
333
|
const cfg = config;
|
|
334
|
+
const thresholds = thresholdsFor(cfg, activeModel);
|
|
232
335
|
const capturedGeneration = activeGeneration;
|
|
233
336
|
const capturedDeadlineEpoch = ++deadlineEpoch;
|
|
234
|
-
const threshold =
|
|
337
|
+
const threshold = thresholds.firstEventMs;
|
|
235
338
|
const run = () => {
|
|
236
339
|
if (capturedGeneration !== activeGeneration || capturedDeadlineEpoch !== deadlineEpoch || !activeRun || firstEventSeen) return;
|
|
237
340
|
const elapsed = runtime.now() - lastSemanticAt;
|
|
@@ -240,15 +343,16 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
|
|
|
240
343
|
return;
|
|
241
344
|
}
|
|
242
345
|
abortStall(ctx, capturedGeneration, {
|
|
243
|
-
retry: () => firstEventRetryNotice(
|
|
244
|
-
exhausted: () => firstEventExhaustedNotice(
|
|
245
|
-
}, `Provider first-event timeout after ${
|
|
346
|
+
retry: () => firstEventRetryNotice(thresholds),
|
|
347
|
+
exhausted: () => firstEventExhaustedNotice(thresholds),
|
|
348
|
+
}, `Provider first-event timeout after ${thresholds.firstEventMs} ms without a stream event`);
|
|
246
349
|
};
|
|
247
350
|
timers.firstEvent = runtime.setTimeout(run, threshold);
|
|
248
351
|
};
|
|
249
352
|
const schedule = (ctx: { abort(): void }) => {
|
|
250
353
|
if (activeGeneration === undefined || !config) return;
|
|
251
354
|
const cfg = config;
|
|
355
|
+
const thresholds = thresholdsFor(cfg, activeModel);
|
|
252
356
|
const capturedGeneration = activeGeneration;
|
|
253
357
|
const capturedDeadlineEpoch = ++deadlineEpoch;
|
|
254
358
|
const run = (kind: "warning" | "recovery", threshold: number) => () => {
|
|
@@ -260,20 +364,25 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
|
|
|
260
364
|
}
|
|
261
365
|
if (kind === "warning" && !warned) {
|
|
262
366
|
warned = true;
|
|
263
|
-
announce(warningNotice(
|
|
367
|
+
announce(warningNotice(thresholds), "warning");
|
|
264
368
|
}
|
|
265
369
|
if (kind === "recovery") {
|
|
266
370
|
abortStall(ctx, capturedGeneration, {
|
|
267
371
|
retry: () => `No model progress for ${formatElapsed(elapsed)}; aborting now. Pi will retry (${stallRetriesUsed}/${cfg.maxStallRetries}) if retry is enabled and capacity remains. Pending follow-ups are returned to the editor.`,
|
|
268
372
|
exhausted: () => exhaustedNotice(cfg),
|
|
269
|
-
}, `Provider semantic timeout after ${
|
|
373
|
+
}, `Provider semantic timeout after ${thresholds.recoveryMs} ms without progress`);
|
|
270
374
|
}
|
|
271
375
|
};
|
|
272
|
-
timers.warning = runtime.setTimeout(run("warning",
|
|
273
|
-
timers.recovery = runtime.setTimeout(run("recovery",
|
|
376
|
+
timers.warning = runtime.setTimeout(run("warning", thresholds.warningMs), thresholds.warningMs);
|
|
377
|
+
timers.recovery = runtime.setTimeout(run("recovery", thresholds.recoveryMs), thresholds.recoveryMs);
|
|
274
378
|
};
|
|
275
379
|
|
|
276
380
|
pi.on("before_provider_request", (_event, ctx) => {
|
|
381
|
+
if (redriveTimer !== undefined) resetRunState();
|
|
382
|
+
if (redriveInFlight) {
|
|
383
|
+
redriveInFlight = false;
|
|
384
|
+
continuationStarted = true;
|
|
385
|
+
} else if (convertedTimeout) continuationStarted = true;
|
|
277
386
|
if (disabled) return;
|
|
278
387
|
ui = ctx.ui;
|
|
279
388
|
hasUI = ctx.hasUI;
|
|
@@ -289,8 +398,8 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
|
|
|
289
398
|
activeRun = config.enabled;
|
|
290
399
|
if (!activeRun) return;
|
|
291
400
|
disarm();
|
|
292
|
-
if (convertedTimeout) continuationStarted = true;
|
|
293
401
|
activeGeneration = ++generation;
|
|
402
|
+
activeModel = ctx.model;
|
|
294
403
|
lastSemanticAt = runtime.now();
|
|
295
404
|
const target = ctx.signal;
|
|
296
405
|
if (target) {
|
|
@@ -338,17 +447,67 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
|
|
|
338
447
|
&& activeGeneration === watchdogAbortedGeneration;
|
|
339
448
|
disarm();
|
|
340
449
|
// Mirror Pi's retry loop, which resets its attempt counter on any successful assistant turn.
|
|
341
|
-
if (event.message.stopReason !== "aborted" && event.message.stopReason !== "error") stallRetriesUsed = 0;
|
|
450
|
+
if (event.message.stopReason !== "aborted" && event.message.stopReason !== "error") { stallRetriesUsed = 0; convertedTimeout = false; redrivePending = false; redriveEligible = false; }
|
|
342
451
|
if (!matchesWatchdogAbort) return;
|
|
343
452
|
convertedTimeout = true;
|
|
453
|
+
redrivePending = true;
|
|
454
|
+
redriveEligible = false;
|
|
344
455
|
continuationStarted = false;
|
|
345
456
|
return { message: { ...event.message, stopReason: "error", errorMessage } };
|
|
346
457
|
});
|
|
458
|
+
pi.on("turn_end", (event, ctx) => {
|
|
459
|
+
// The converted generation is watchdogAbortedGeneration until this turn ends.
|
|
460
|
+
if (!redrivePending) return;
|
|
461
|
+
redrivePending = false;
|
|
462
|
+
const settings = resolveRetrySettings(ctx.cwd);
|
|
463
|
+
redriveEligible = settings.enabled;
|
|
464
|
+
if (!redriveEligible) return;
|
|
465
|
+
redriveDelay = redriveDelayMs(settings, stallRetriesUsed);
|
|
466
|
+
return { entries: [...event.entries, { type: "context_edit" as const, targetId: event.messageEntryId, replacement: null }] };
|
|
467
|
+
});
|
|
468
|
+
// Re-drive exists because pi >= 0.86 fences the whole run on session abort; delete it once pi offers a request-scoped abort.
|
|
469
|
+
const sendRedrive = () => {
|
|
470
|
+
cancelRedrive();
|
|
471
|
+
redriveInFlight = true;
|
|
472
|
+
redriveEligible = false;
|
|
473
|
+
try { pi.sendMessage({ customType: "provider-stall-watchdog", content: REDRIVE_TEXT, display: false }, { triggerTurn: true }); }
|
|
474
|
+
catch (error) { resetRunState(); announce(`providerStallWatchdog: automatic retry failed to start: ${error instanceof Error ? error.message : String(error)}`, "error"); }
|
|
475
|
+
};
|
|
476
|
+
const awaitRedriveDelay = () => new Promise<boolean>((resolve) => {
|
|
477
|
+
redriveResolve = resolve;
|
|
478
|
+
redriveTimer = runtime.setTimeout(() => { redriveTimer = undefined; redriveResolve = undefined; resolve(true); }, redriveDelay);
|
|
479
|
+
});
|
|
480
|
+
const armRedrive = (ctx: { ui: { setStatus(key: string, text: string | undefined): void; onTerminalInput(handler: (data: string) => any): () => void } }) => {
|
|
481
|
+
statusUI = ctx.ui;
|
|
482
|
+
const deadline = runtime.now() + redriveDelay;
|
|
483
|
+
const tick = () => {
|
|
484
|
+
if (redriveTimer === undefined || !config) return;
|
|
485
|
+
ctx.ui.setStatus(STATUS_KEY, `Retrying (${stallRetriesUsed}/${config.maxStallRetries}) in ${Math.ceil(Math.max(0, deadline - runtime.now()) / 1000)}s... (Esc to cancel)`);
|
|
486
|
+
if (deadline - runtime.now() > 1000) statusTimer = runtime.setTimeout(tick, 1000);
|
|
487
|
+
};
|
|
488
|
+
redriveTimer = runtime.setTimeout(sendRedrive, redriveDelay);
|
|
489
|
+
removeTerminalInput = ctx.ui.onTerminalInput((data) => {
|
|
490
|
+
if (data !== "\x1b") return;
|
|
491
|
+
resetRunState(); announce(RETRY_CANCELLED_NOTICE);
|
|
492
|
+
return { consume: true };
|
|
493
|
+
});
|
|
494
|
+
tick();
|
|
495
|
+
};
|
|
347
496
|
pi.on("agent_end", () => disarm());
|
|
348
|
-
pi.on("agent_settled", () => {
|
|
349
|
-
if (
|
|
497
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
498
|
+
if (exhaustedAbortGeneration !== undefined && exhaustedAbortGeneration === watchdogAbortedGeneration) { announce(DEGRADATION_NOTICE); resetRunState(); return; }
|
|
499
|
+
if (convertedTimeout && continuationStarted) { resetRunState(); return; }
|
|
500
|
+
if (convertedTimeout && !redriveEligible) { announce(DEGRADATION_NOTICE); resetRunState(); return; }
|
|
501
|
+
if (redriveEligible) {
|
|
502
|
+
if (ctx.mode === "tui" || ctx.mode === "rpc") armRedrive(ctx);
|
|
503
|
+
else { if (await awaitRedriveDelay()) sendRedrive(); }
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
350
506
|
resetRunState();
|
|
351
507
|
});
|
|
508
|
+
pi.on("input", () => { if (redriveTimer !== undefined || redriveInFlight) resetRunState(); });
|
|
509
|
+
pi.on("session_before_tree", () => { if (redriveTimer !== undefined) resetRunState(); });
|
|
510
|
+
pi.on("session_before_compact", () => { if (redriveTimer !== undefined) resetRunState(); });
|
|
352
511
|
pi.on("session_shutdown", () => {
|
|
353
512
|
resetRunState();
|
|
354
513
|
config = undefined;
|
package/lib/extension-config.ts
CHANGED
|
@@ -46,7 +46,7 @@ export const QUIVER_CONFIG_KEYS: Record<string, readonly string[]> = {
|
|
|
46
46
|
fastMode: ["enabled"],
|
|
47
47
|
sessionAutoName: ["enabled", "ghosttyTab", "herdrTab", "rules", "deny", "revisitFirstTurn", "revisitEveryTurns"],
|
|
48
48
|
swordHeader: ["enabled"],
|
|
49
|
-
providerStallWatchdog: ["enabled", "firstEventMs", "warningMs", "recoveryMs", "maxStallRetries"],
|
|
49
|
+
providerStallWatchdog: ["enabled", "firstEventMs", "warningMs", "recoveryMs", "maxStallRetries", "models"],
|
|
50
50
|
slack: ["enabled", "cachePath", "policyPath", "userTokenEnv", "userTokenCommand", "userTokenCommandTimeoutSeconds", "botTokenEnv", "uploadThresholdChars"],
|
|
51
51
|
docToMd: DOC_TO_MD_OPTIONS.filter((o) => o.settable).map((o) => o.key),
|
|
52
52
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-quiver",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.4.0",
|
|
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",
|
|
@@ -87,9 +87,9 @@
|
|
|
87
87
|
"unpdf": "^1.8.1"
|
|
88
88
|
},
|
|
89
89
|
"devDependencies": {
|
|
90
|
-
"@earendil-works/pi-ai": "^0.
|
|
91
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
92
|
-
"@earendil-works/pi-tui": "^0.
|
|
90
|
+
"@earendil-works/pi-ai": "^0.87.1",
|
|
91
|
+
"@earendil-works/pi-coding-agent": "^0.87.1",
|
|
92
|
+
"@earendil-works/pi-tui": "^0.87.1",
|
|
93
93
|
"@sinclair/typebox": "^0.34.49",
|
|
94
94
|
"@types/jsdom": "^28.0.3",
|
|
95
95
|
"@types/node": "^26.1.0",
|