pi-quiver 6.2.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 +8 -0
- package/README.md +5 -5
- package/extensions/fast-mode.ts +2 -2
- package/extensions/provider-stall-watchdog.ts +110 -4
- package/extensions/session-name.ts +40 -31
- 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.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
|
+
|
|
15
|
+
## v6.3.0 - 2026-09-16
|
|
16
|
+
|
|
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.
|
|
18
|
+
|
|
11
19
|
## v6.2.0 - 2026-09-15
|
|
12
20
|
|
|
13
21
|
- `slack_thread` flattens Block Kit blocks in its default rendering (blocks joined by ` / `, `[<type>]` for unknown types) and gains an opt-in `raw: true` mode returning thread messages as a pure JSON array for `slack_update` round-trips; both modes go through the existing size gate. (#21)
|
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).
|
|
@@ -200,7 +200,7 @@ survive untouched.
|
|
|
200
200
|
|
|
201
201
|
`sessionAutoName.enabled` makes one extra short LLM call per session (once, after the first turn) to title it; `false` (default) makes no model calls. `rules` appends house conventions to the naming prompt (later rules win when they conflict with the built-ins). Literal, case-insensitive `deny` phrases are stripped from every name; whitespace inside a phrase is loose, so `"acme corp"` also catches `AcmeCorp`. `revisitFirstTurn` re-evaluates the name once that many model round trips have completed, while `revisitEveryTurns` does so at every multiple; both default to `0` (off) because each revisit costs another short LLM call. For example, `10` and `100` mark round trips 10, 100, 200, 300. Revisits only run when the agent has fully settled (idle, nothing queued) - an automated multi-turn run such as a subagent chain is never renamed or delayed mid-flight; cadence points it crossed fire once, at the settle. A machine-generated name is replaced when stale. A name set by a human is never overwritten: the extension strongly prefers it, and announces a suggestion only when the work has clearly moved on. Counts come from the persisted transcript, so they survive resume.
|
|
202
202
|
|
|
203
|
-
`herdrTab` (default `true`) mirrors the same curated label to the Herdr tab bar over Herdr's unix socket, independent of `ghosttyTab` - either sink can be toggled off without affecting the other.
|
|
203
|
+
`herdrTab` (default `true`) mirrors the same curated label to the Herdr tab bar over Herdr's unix socket, independent of `ghosttyTab` - either sink can be toggled off without affecting the other. A tab is claimable while its label is a bare number (`1`, `4`, ...) at any position - Herdr's vocabulary for "unnamed" - so reordering tabs before or after naming never matters; a tab you deliberately name `3` is taken over too. A non-numeric label is treated as a human's and is never overwritten; the check repeats every turn, so renumbering the tab by hand hands it back. Exactly one leading `* ` (herdr-ntfy-notify's armed marker) is not a human rename - it is preserved across renames and the shutdown restore, and removing it keeps the claim. Every session claims afresh: on each shutdown (quit, reload, `/new`, resume, fork) a claimed tab is restored to its then-current position number, and the successor session in the pane claims it again on its first name. The label the extension writes is never digits-only - a bare `1234` becomes `#1234`, and the naming prompt asks the model for `PR 1234` / `issue 123` / `ticket ABC-123` instead of a bare ID. It only ever runs in TUI mode, on an attached TTY, under Herdr (`HERDR_ENV`/`HERDR_TAB_ID`/`HERDR_SOCKET_PATH` all set) - `pi -p`/json/rpc runs and background subagents never touch the tab. Caveat: the restored number is internally a custom name (Herdr has no clear-to-auto API), so that tab keeps a number-looking label but stops renumbering on later tab closes/reorders.
|
|
204
204
|
|
|
205
205
|
`fastMode` only affects `claude-opus-4-8` and `claude-opus-5` requests on Anthropic's `anthropic-messages` API; enabling it opts into premium fast-mode pricing. `--fast` forces it on for one launch; `/fast on|off` toggles live. Proxy providers (opencode, cloudflare-ai-gateway) are excluded. `fastMode`'s header injection needs the `before_provider_headers` hook (pi bundling `@earendil-works/pi-coding-agent` >= 0.80.5); on older pi the beta header is silently not sent. The `anthropic-beta` header is discovered at request time by probing pi's own request assembly (no network), so conditional betas pi adds - e.g. `server-side-fallback-2026-07-01` for models with server-side fallback - are preserved alongside `fast-mode-2026-02-01`. If the probe fails, the header falls back to the static OAuth-identity + fast-mode list. See [doc/fetch.md](doc/fetch.md) and [doc/doc-to-md.md](doc/doc-to-md.md) for the ingestion tools' full reference; session-name/sword-header behavior above is complete.
|
|
206
206
|
|
|
@@ -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 +
|
|
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.
|
|
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
|
|
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;
|
|
@@ -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 (
|
|
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;
|
|
@@ -173,12 +173,15 @@ export function isGhosttyActive(
|
|
|
173
173
|
}
|
|
174
174
|
|
|
175
175
|
export function toTabLabel(name: string, maxWords = 4): string {
|
|
176
|
-
|
|
176
|
+
const label = name
|
|
177
177
|
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
178
178
|
.split(/\s+/)
|
|
179
179
|
.filter(Boolean)
|
|
180
180
|
.slice(0, maxWords)
|
|
181
181
|
.join(" ");
|
|
182
|
+
// A bare number is Herdr's "unnamed tab" vocabulary; our own label must
|
|
183
|
+
// never look like one or a successor session would treat it as claimable.
|
|
184
|
+
return /^\d+$/.test(label) ? `#${label}` : label;
|
|
182
185
|
}
|
|
183
186
|
|
|
184
187
|
/**
|
|
@@ -338,6 +341,7 @@ export function buildNamingPrompt(conversation: string, opts: PromptOptions = {}
|
|
|
338
341
|
"- Describe the actual task, NOT the tool/skill/command used to start it.",
|
|
339
342
|
"- Lead with an action verb (e.g. refine, fix, add, rework).",
|
|
340
343
|
"- Preserve ticket/issue IDs (e.g. ABC-123, PROJ-42, #99) verbatim.",
|
|
344
|
+
"- If the whole TAB would be just an ID, say what it is: PR 1234, issue 123, ticket ABC-123. Never reply with digits only.",
|
|
341
345
|
"- No quotes, no trailing punctuation, plain ASCII.",
|
|
342
346
|
...rules.map((r) => `- ${r}`),
|
|
343
347
|
"Example -> SESSION: Refine Linear Ticket ABC-123 / TAB: Refine ABC-123",
|
|
@@ -496,12 +500,15 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
496
500
|
if (currentTabLabel) renameGhosttyTab(currentTabLabel, cfg.ghosttyTab);
|
|
497
501
|
};
|
|
498
502
|
|
|
499
|
-
// Herdr sink.
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
503
|
+
// Herdr sink. A tab is claimable while its label is a bare number - Herdr's
|
|
504
|
+
// vocabulary for "unnamed", and the only label this extension or
|
|
505
|
+
// herdr-ntfy-notify ever write back - regardless of position: Herdr stores a
|
|
506
|
+
// renamed number as a permanent custom label that reorders never update. A
|
|
507
|
+
// non-numeric label is a human's and is never overwritten; it is re-checked
|
|
508
|
+
// each turn so a tab renumbered by hand becomes claimable again. Exactly one
|
|
509
|
+
// leading "* " is herdr-ntfy-notify's armed marker, ignored for ownership and
|
|
510
|
+
// carried through on every write. All syncs serialize on one chain so
|
|
511
|
+
// overlapping hooks never interleave a read with a rename.
|
|
505
512
|
// Used both for turn_start syncs (bounds a wedged-but-accepting Herdr so it
|
|
506
513
|
// can't stall the turn) and for the session_end restore.
|
|
507
514
|
const HERDR_TIMEOUT_MS = 500;
|
|
@@ -514,44 +521,42 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
514
521
|
};
|
|
515
522
|
|
|
516
523
|
const ARMED_PREFIX = "* ";
|
|
524
|
+
const NUMERIC = /^\d+$/;
|
|
517
525
|
const matchOwned = (live: string, expected: string): { owned: boolean; armed: boolean } => {
|
|
518
526
|
if (live === expected) return { owned: true, armed: false };
|
|
519
527
|
if (live === ARMED_PREFIX + expected) return { owned: true, armed: true };
|
|
520
528
|
return { owned: false, armed: false };
|
|
521
529
|
};
|
|
530
|
+
const matchClaimable = (live: string): { claimable: boolean; armed: boolean } => {
|
|
531
|
+
if (NUMERIC.test(live)) return { claimable: true, armed: false };
|
|
532
|
+
if (live.startsWith(ARMED_PREFIX) && NUMERIC.test(live.slice(ARMED_PREFIX.length))) return { claimable: true, armed: true };
|
|
533
|
+
return { claimable: false, armed: false };
|
|
534
|
+
};
|
|
522
535
|
|
|
523
536
|
const syncHerdrTab = (cfg: Config, label: string | null, mode: Mode | undefined): Promise<void> => {
|
|
524
537
|
const run = async (): Promise<void> => {
|
|
525
538
|
if (!cfg.herdrTab || mode !== "tui" || !label) return;
|
|
526
|
-
if (herdrClaim === "backed-off") return;
|
|
527
539
|
if (!isHerdrActive()) return;
|
|
528
540
|
const sock = process.env.HERDR_SOCKET_PATH as string;
|
|
529
541
|
const tabId = process.env.HERDR_TAB_ID as string;
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
const
|
|
534
|
-
if (
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
if (!owned.owned) {
|
|
538
|
-
herdrClaim = "backed-off"; // human (or crashed predecessor) owns it
|
|
542
|
+
const live = await getTab(sock, tabId, HERDR_TIMEOUT_MS);
|
|
543
|
+
if (!live) return; // transient or stale tab id; a failed read is never a human rename
|
|
544
|
+
if (typeof herdrClaim === "object" && herdrClaim !== null) {
|
|
545
|
+
const owned = matchOwned(live.label, herdrClaim.lastWritten);
|
|
546
|
+
if (owned.owned) {
|
|
547
|
+
if (label === herdrClaim.lastWritten) return;
|
|
548
|
+
if (await renameTab(sock, tabId, (owned.armed ? ARMED_PREFIX : "") + label, HERDR_TIMEOUT_MS)) herdrClaim.lastWritten = label;
|
|
539
549
|
return;
|
|
540
550
|
}
|
|
541
|
-
if (await renameTab(sock, tabId, (owned.armed ? ARMED_PREFIX : "") + label, HERDR_TIMEOUT_MS)) {
|
|
542
|
-
herdrClaim = { lastWritten: label };
|
|
543
|
-
}
|
|
544
|
-
return;
|
|
545
551
|
}
|
|
546
|
-
const
|
|
547
|
-
if (!
|
|
548
|
-
|
|
549
|
-
if (!owned.owned) {
|
|
550
|
-
herdrClaim = "backed-off";
|
|
552
|
+
const claim = matchClaimable(live.label);
|
|
553
|
+
if (!claim.claimable) {
|
|
554
|
+
herdrClaim = "backed-off"; // human owns it until the label is numeric again
|
|
551
555
|
return;
|
|
552
556
|
}
|
|
553
|
-
if (
|
|
554
|
-
|
|
557
|
+
if (await renameTab(sock, tabId, (claim.armed ? ARMED_PREFIX : "") + label, HERDR_TIMEOUT_MS)) {
|
|
558
|
+
herdrClaim = { lastWritten: label };
|
|
559
|
+
}
|
|
555
560
|
};
|
|
556
561
|
herdrChain = herdrChain.then(run, run);
|
|
557
562
|
return herdrChain;
|
|
@@ -595,6 +600,10 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
595
600
|
});
|
|
596
601
|
|
|
597
602
|
pi.on("session_start", async (_event, ctx) => {
|
|
603
|
+
// Every session claims afresh, exactly like a fresh process: /new, resume,
|
|
604
|
+
// and fork each tear the previous session down (restore included) first.
|
|
605
|
+
// Before loadConfig so `enabled: false, herdrTab: true` resets too.
|
|
606
|
+
herdrClaim = null;
|
|
598
607
|
const cfg = loadConfig(ctx);
|
|
599
608
|
if (!cfg.enabled) return; // off by default; opt in via settings.json
|
|
600
609
|
const current = pi.getSessionName();
|
|
@@ -646,9 +655,9 @@ export function installSessionName(pi: ExtensionAPI, generate: NameGenerator = g
|
|
|
646
655
|
});
|
|
647
656
|
|
|
648
657
|
// Restore the numeric label on every teardown reason (quit/reload/new/
|
|
649
|
-
// resume/fork)
|
|
650
|
-
//
|
|
651
|
-
//
|
|
658
|
+
// resume/fork) so the pane reads as unnamed again. The restored label is
|
|
659
|
+
// internally a custom name - Herdr has no clear-to-auto API - so it won't
|
|
660
|
+
// renumber on reorders; the successor claims any numeric label regardless.
|
|
652
661
|
pi.on("session_shutdown", async () => {
|
|
653
662
|
await restoreHerdrTab();
|
|
654
663
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-quiver",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
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",
|