pi-quiver 6.3.0 → 6.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,10 @@ 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.3.1 - 2026-09-25
12
+
13
+ - `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`.
14
+
11
15
  ## v6.3.0 - 2026-09-16
12
16
 
13
17
  - `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. Policy D offers each stall to Pi's retry loop until the stall retry budget (`maxStallRetries`, default = `retry.maxRetries`) is exhausted. 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. 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).
@@ -239,18 +239,18 @@ not a pi-quiver setting and is never nested):
239
239
 
240
240
  `providerStallWatchdog` is OFF by default. Once enabled it arms in two tiers per provider request:
241
241
 
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 + Pi's 2s backoff) instead of the ~240s it took when only the mid-stream tier existed.
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 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
243
  - **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
244
 
245
245
  **Raise `firstEventMs` if your provider is legitimately slow to first event.** 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
246
 
247
247
  **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
248
 
249
- Verified with Pi 0.80.10: each stall is aborted and offered to Pi retry 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". 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). 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.
249
+ 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
250
 
251
251
  Operational notes:
252
252
 
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.
253
+ - **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
254
  - **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
255
  - **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
256
 
@@ -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;
@@ -91,6 +91,27 @@ export function resolveRetryMaxRetries(cwd: string): number {
91
91
  return maxRetries;
92
92
  }
93
93
 
94
+ export type RetrySettings = { enabled: boolean; baseDelayMs: number; maxAgentDelayMs: number };
95
+
96
+ export function resolveRetrySettings(cwd: string): RetrySettings {
97
+ const settings: RetrySettings = { enabled: true, baseDelayMs: 2_000, maxAgentDelayMs: 60_000 };
98
+ for (const path of settingsPaths(cwd)) {
99
+ const retry = readSettings(path)?.retry;
100
+ if (retry === null || typeof retry !== "object" || Array.isArray(retry)) continue;
101
+ const source = retry as Record<string, unknown>;
102
+ if (typeof source.enabled === "boolean") settings.enabled = source.enabled;
103
+ if (isNonNegativeInteger(source.baseDelayMs)) settings.baseDelayMs = source.baseDelayMs;
104
+ if (isNonNegativeInteger(source.maxAgentDelayMs)) settings.maxAgentDelayMs = source.maxAgentDelayMs;
105
+ }
106
+ return settings;
107
+ }
108
+
109
+ export function redriveDelayMs(settings: Pick<RetrySettings, "baseDelayMs" | "maxAgentDelayMs">, attempt: number): number {
110
+ const delay = settings.baseDelayMs * 2 ** Math.max(0, attempt - 1);
111
+ const safeDelay = Number.isSafeInteger(delay) ? delay : Number.MAX_SAFE_INTEGER;
112
+ return Math.min(safeDelay, settings.maxAgentDelayMs, MAX_TIMER_MS);
113
+ }
114
+
94
115
  export function resolveWatchdogConfig(cwd: string, warn?: (msg: string) => void): ConfigValidation {
95
116
  const candidate = resolveConfig(cwd, "providerStallWatchdog", DEFAULT_CANDIDATE, coerce, warn);
96
117
  if (candidate.blockIsObject === true && candidate.maxStallRetries === undefined) {
@@ -105,6 +126,9 @@ const defaultRuntime: WatchdogRuntime = {
105
126
  clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
106
127
  };
107
128
 
129
+ const REDRIVE_TEXT = "The previous provider request stalled before completing and was retried automatically. Continue.";
130
+ const RETRY_CANCELLED_NOTICE = "Automatic retry cancelled; submit the message again to retry manually.";
131
+ const STATUS_KEY = "providerStallWatchdog";
108
132
  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
133
  // Reduces, but cannot eliminate, the hang when an aborted provider operation never terminates;
110
134
  // undici's headersTimeout/bodyTimeout stay the backstop past this point.
@@ -157,6 +181,16 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
157
181
  let stallRetriesUsed = 0;
158
182
  let continuationStarted = false;
159
183
  let convertedTimeout = false;
184
+ let redrivePending = false;
185
+ let redriveResolve: ((fired: boolean) => void) | undefined;
186
+ let exhaustedAbortGeneration: number | undefined;
187
+ let redriveEligible = false;
188
+ let redriveDelay = 0;
189
+ let redriveTimer: unknown;
190
+ let statusTimer: unknown;
191
+ let redriveInFlight = false;
192
+ let removeTerminalInput: (() => void) | undefined;
193
+ let statusUI: { setStatus(key: string, text: string | undefined): void } | undefined;
160
194
 
161
195
  const clearTimers = () => {
162
196
  for (const key of ["firstEvent", "warning", "recovery", "abortGuard"] as const) {
@@ -175,7 +209,24 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
175
209
  activeGeneration = undefined;
176
210
  };
177
211
  const disarm = () => { clear(); warned = false; };
212
+ const cancelRedrive = () => {
213
+ if (redriveTimer !== undefined) runtime.clearTimeout(redriveTimer);
214
+ redriveTimer = undefined;
215
+ redriveResolve?.(false);
216
+ redriveResolve = undefined;
217
+ if (statusTimer !== undefined) runtime.clearTimeout(statusTimer);
218
+ statusTimer = undefined;
219
+ statusUI?.setStatus(STATUS_KEY, undefined);
220
+ statusUI = undefined;
221
+ removeTerminalInput?.();
222
+ removeTerminalInput = undefined;
223
+ };
178
224
  const resetRunState = () => {
225
+ cancelRedrive();
226
+ redrivePending = false;
227
+ exhaustedAbortGeneration = undefined;
228
+ redriveEligible = false;
229
+ redriveInFlight = false;
179
230
  disarm();
180
231
  activeRun = false;
181
232
  stallRetriesUsed = 0;
@@ -217,6 +268,7 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
217
268
  // the generation check makes the callback a no-op if that teardown disarmed the watchdog.
218
269
  armAbortGuard(capturedGeneration);
219
270
  if (stallRetriesUsed >= config.maxStallRetries) {
271
+ exhaustedAbortGeneration = capturedGeneration;
220
272
  announce(notices.exhausted());
221
273
  ctx.abort();
222
274
  return;
@@ -274,6 +326,11 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
274
326
  };
275
327
 
276
328
  pi.on("before_provider_request", (_event, ctx) => {
329
+ if (redriveTimer !== undefined) resetRunState();
330
+ if (redriveInFlight) {
331
+ redriveInFlight = false;
332
+ continuationStarted = true;
333
+ } else if (convertedTimeout) continuationStarted = true;
277
334
  if (disabled) return;
278
335
  ui = ctx.ui;
279
336
  hasUI = ctx.hasUI;
@@ -289,7 +346,6 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
289
346
  activeRun = config.enabled;
290
347
  if (!activeRun) return;
291
348
  disarm();
292
- if (convertedTimeout) continuationStarted = true;
293
349
  activeGeneration = ++generation;
294
350
  lastSemanticAt = runtime.now();
295
351
  const target = ctx.signal;
@@ -338,17 +394,67 @@ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRu
338
394
  && activeGeneration === watchdogAbortedGeneration;
339
395
  disarm();
340
396
  // 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;
397
+ if (event.message.stopReason !== "aborted" && event.message.stopReason !== "error") { stallRetriesUsed = 0; convertedTimeout = false; redrivePending = false; redriveEligible = false; }
342
398
  if (!matchesWatchdogAbort) return;
343
399
  convertedTimeout = true;
400
+ redrivePending = true;
401
+ redriveEligible = false;
344
402
  continuationStarted = false;
345
403
  return { message: { ...event.message, stopReason: "error", errorMessage } };
346
404
  });
405
+ pi.on("turn_end", (event, ctx) => {
406
+ // The converted generation is watchdogAbortedGeneration until this turn ends.
407
+ if (!redrivePending) return;
408
+ redrivePending = false;
409
+ const settings = resolveRetrySettings(ctx.cwd);
410
+ redriveEligible = settings.enabled;
411
+ if (!redriveEligible) return;
412
+ redriveDelay = redriveDelayMs(settings, stallRetriesUsed);
413
+ return { entries: [...event.entries, { type: "context_edit" as const, targetId: event.messageEntryId, replacement: null }] };
414
+ });
415
+ // Re-drive exists because pi >= 0.86 fences the whole run on session abort; delete it once pi offers a request-scoped abort.
416
+ const sendRedrive = () => {
417
+ cancelRedrive();
418
+ redriveInFlight = true;
419
+ redriveEligible = false;
420
+ try { pi.sendMessage({ customType: "provider-stall-watchdog", content: REDRIVE_TEXT, display: false }, { triggerTurn: true }); }
421
+ catch (error) { resetRunState(); announce(`providerStallWatchdog: automatic retry failed to start: ${error instanceof Error ? error.message : String(error)}`, "error"); }
422
+ };
423
+ const awaitRedriveDelay = () => new Promise<boolean>((resolve) => {
424
+ redriveResolve = resolve;
425
+ redriveTimer = runtime.setTimeout(() => { redriveTimer = undefined; redriveResolve = undefined; resolve(true); }, redriveDelay);
426
+ });
427
+ const armRedrive = (ctx: { ui: { setStatus(key: string, text: string | undefined): void; onTerminalInput(handler: (data: string) => any): () => void } }) => {
428
+ statusUI = ctx.ui;
429
+ const deadline = runtime.now() + redriveDelay;
430
+ const tick = () => {
431
+ if (redriveTimer === undefined || !config) return;
432
+ ctx.ui.setStatus(STATUS_KEY, `Retrying (${stallRetriesUsed}/${config.maxStallRetries}) in ${Math.ceil(Math.max(0, deadline - runtime.now()) / 1000)}s... (Esc to cancel)`);
433
+ if (deadline - runtime.now() > 1000) statusTimer = runtime.setTimeout(tick, 1000);
434
+ };
435
+ redriveTimer = runtime.setTimeout(sendRedrive, redriveDelay);
436
+ removeTerminalInput = ctx.ui.onTerminalInput((data) => {
437
+ if (data !== "\x1b") return;
438
+ resetRunState(); announce(RETRY_CANCELLED_NOTICE);
439
+ return { consume: true };
440
+ });
441
+ tick();
442
+ };
347
443
  pi.on("agent_end", () => disarm());
348
- pi.on("agent_settled", () => {
349
- if (convertedTimeout && !continuationStarted) announce(DEGRADATION_NOTICE);
444
+ pi.on("agent_settled", async (_event, ctx) => {
445
+ if (exhaustedAbortGeneration !== undefined && exhaustedAbortGeneration === watchdogAbortedGeneration) { announce(DEGRADATION_NOTICE); resetRunState(); return; }
446
+ if (convertedTimeout && continuationStarted) { resetRunState(); return; }
447
+ if (convertedTimeout && !redriveEligible) { announce(DEGRADATION_NOTICE); resetRunState(); return; }
448
+ if (redriveEligible) {
449
+ if (ctx.mode === "tui" || ctx.mode === "rpc") armRedrive(ctx);
450
+ else { if (await awaitRedriveDelay()) sendRedrive(); }
451
+ return;
452
+ }
350
453
  resetRunState();
351
454
  });
455
+ pi.on("input", () => { if (redriveTimer !== undefined || redriveInFlight) resetRunState(); });
456
+ pi.on("session_before_tree", () => { if (redriveTimer !== undefined) resetRunState(); });
457
+ pi.on("session_before_compact", () => { if (redriveTimer !== undefined) resetRunState(); });
352
458
  pi.on("session_shutdown", () => {
353
459
  resetRunState();
354
460
  config = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-quiver",
3
- "version": "6.3.0",
3
+ "version": "6.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",
@@ -87,9 +87,9 @@
87
87
  "unpdf": "^1.8.1"
88
88
  },
89
89
  "devDependencies": {
90
- "@earendil-works/pi-ai": "^0.84.2",
91
- "@earendil-works/pi-coding-agent": "^0.84.2",
92
- "@earendil-works/pi-tui": "^0.84.2",
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",