pi-quiver 3.2.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,17 @@ Published to npm as `pi-quiver` (`pi install npm:pi-quiver`). Pushing a
8
8
  via OIDC trusted publishing. The release helper at
9
9
  `.agents/skills/release/scripts/release.sh` cuts the tag; CI publishes.
10
10
 
11
+ ## v3.3.1 - 2026-07-19
12
+
13
+ - **`provider-stall-watchdog`: bounded multi-stall retry via `maxStallRetries`.** Each semantic stall converts to a retryable error until the budget is exhausted; previously only the first stall retried, so a longer provider outage forced manual resubmission (observed stuck streams recover in seconds on a fresh request). Default = layered `retry.maxRetries` (Pi default 3); explicit config wins; positive integer, fails closed. A successful assistant turn resets the counter, mirroring Pi's retry loop.
14
+ - **Watchdog notices moved to the main window.** The silence warning is now a `notify(..., "warning")` transcript line instead of a bottom status-line entry; the status-line integration is removed. Recovery notices report the attempt as `(used/max)`.
15
+
16
+ ## v3.3.0 - 2026-07-17
17
+
18
+ - **`provider-stall-watchdog`: opt-in semantic-silence recovery.** Policy D warns after configured silence, aborts the first semantic stall, and offers it once to Pi's existing retry loop; a second stall stops without another watchdog retry. OFF by default.
19
+ - **Human-TUI boundary.** Arms only for confirmed human interactive TUI runs; JSON, RPC, print, and subagent runs are excluded by activation rather than environment or session lineage.
20
+ - **Compatibility and fallback.** Verified against Pi 0.80.10. Automatic continuation requires enabled Pi retry with remaining capacity; disabled, exhausted, or incompatible retry leaves the request stopped for manual resubmission. Pending steering and follow-ups return to the editor and are excluded from automatic continuation; invalid merged watchdog configuration fails closed.
21
+
11
22
  ## v3.2.0 - 2026-07-12
12
23
 
13
24
  - Add `fast-mode` extension: opt-in Anthropic fast mode for Claude Opus 4.8 (`speed: "fast"` payload + `fast-mode-2026-02-01` beta header), controlled via `fastMode` settings key, `--fast` flag, and `/fast [on|off|status]`. OFF by default. Preserves OAuth identity betas. Requires pi bundling `@earendil-works/pi-coding-agent` >= 0.80.5 (the `before_provider_headers` hook).
package/README.md CHANGED
@@ -18,7 +18,7 @@ But the moment an agent does that, one `fetch` or PDF read can dump hundreds of
18
18
 
19
19
  `fetch` and `doc_to_md` bring real web pages, GitHub issues/PRs, and local PDF/DOCX/PPTX files into context - and every result is size-gated by construction: over 32 KB or 1000 lines spills to a temp file with a preview and a grep/read hint, so a single call can never flood the window. Ingestion is what makes data-driven work possible; the gate is what keeps it safe.
20
20
 
21
- `session-name` and `sword-header` are smaller, opt-in ergonomics on top - session labeling and a themed startup header.
21
+ `session-name`, `sword-header`, `fast-mode`, and `provider-stall-watchdog` are opt-in ergonomics and recovery controls: session labeling, a themed startup header, Anthropic fast mode, and semantic-stall recovery.
22
22
 
23
23
  ## Part of the pi agent toolkit
24
24
 
@@ -33,7 +33,7 @@ No code dependency between them. pi-quiver is call-level: it gates the size of w
33
33
 
34
34
  ## Mental model
35
35
 
36
- Every extension here is context-safe by construction, not by convention: the size check runs on every call, there's no flag to forget. Two tools bring real sources in (`fetch`, `doc_to_md`); two are opt-in ergonomics (`session-name`, `sword-header`).
36
+ Every ingestion extension here is context-safe by construction, not by convention: the size check runs on every call, there's no flag to forget. `fetch` and `doc_to_md` bring real sources in; `session-name`, `sword-header`, `fast-mode`, and `provider-stall-watchdog` are opt-in.
37
37
 
38
38
  ```mermaid
39
39
  flowchart LR
@@ -67,6 +67,7 @@ A 300 KB changelog page never touches your context window - you get a preview an
67
67
  | `session-name.ts` | `/session-name` | Manual + opt-in automatic session naming, with Ghostty tab rename. OFF by default. |
68
68
  | `sword-header.ts` | `/builtin-header` | Themed ASCII startup header replacing pi's default logo. OFF by default. |
69
69
  | `fast-mode.ts` | `/fast` | Inject Anthropic fast-mode (`speed: "fast"` + `anthropic-beta: fast-mode-2026-02-01`) into every Claude Opus 4.8 request, any thinking level. `--fast` flag + `/fast [on\|off\|status]`. OFF by default. |
70
+ | `provider-stall-watchdog.ts` | - | Opt-in semantic-silence watchdog for human interactive TUI runs. Warns after 2 minutes and recovers after 4 minutes; policy D offers each stall to Pi's retry loop until the stall retry budget (`maxStallRetries`, default = `retry.maxRetries`) is exhausted. OFF by default. |
70
71
 
71
72
  Full routing rules, size-gate mechanics, and config: [doc/fetch.md](doc/fetch.md), [doc/doc-to-md.md](doc/doc-to-md.md).
72
73
 
@@ -77,18 +78,21 @@ Full routing rules, size-gate mechanics, and config: [doc/fetch.md](doc/fetch.md
77
78
  | Size gate | Text/Markdown/JSON output over 32 KB or 1000 lines spills to a temp file with a 60-line preview instead of inlining. |
78
79
  | Content routing | HTML -> Markdown, binary -> untouched file, GitHub URLs -> `gh` CLI, everything else -> the size gate. |
79
80
  | Graceful degradation | Optional binaries (`gh`, `uv`, LibreOffice) are never hard install-time deps; each has a defined, documented fallback or failure mode. |
80
- | Opt-in ergonomics | `session-name` and `sword-header` do nothing until explicitly enabled in `settings.json`. |
81
+ | Opt-in extensions | `session-name`, `sword-header`, `fast-mode`, and `provider-stall-watchdog` do nothing until explicitly enabled in `settings.json`. |
82
+ | Provider stall recovery | The watchdog detects missing parsed semantic progress, not network liveness. It is limited to confirmed human interactive TUI runs. |
81
83
 
82
84
  ## When to use
83
85
 
84
86
  - An agent needs to reason from a real web page, GitHub issue/PR, or local PDF/DOCX/PPTX instead of memory.
85
87
  - You want that ingestion to be safe by default, with no risk of a single call blowing the context budget.
88
+ - A human interactive Pi session needs an opt-in guard against providers that stop making semantic progress.
86
89
 
87
90
  ## When NOT to use
88
91
 
89
92
  - You need a general-purpose web scraper (JS-rendered pages, pagination, auth flows) - `fetch` does plain HTTP + Readability extraction, nothing more.
90
93
  - You need spreadsheet conversion - `doc_to_md` explicitly excludes spreadsheets (they paginate badly via PDF).
91
- - You want automatic session naming or a custom header without opting in - both stay off until you flip the config.
94
+ - You want automatic session naming, a custom header, fast mode, or stall recovery without opting in - all stay off until you flip the config.
95
+ - You need watchdog behavior in JSON, RPC, print, or subagent runs - activation excludes them by input origin and mode, not environment or session lineage.
92
96
 
93
97
  ## Install
94
98
 
@@ -133,18 +137,39 @@ None is a hard install-time dependency of the package; they are tools you provid
133
137
 
134
138
  ### Opt-in extension config
135
139
 
136
- Both are opt-in via `settings.json` (project `.pi/settings.json` overrides the global agent-dir layer):
140
+ These extensions are opt-in via `settings.json` (project `.pi/settings.json` overrides the global agent-dir layer):
137
141
 
138
142
  ```jsonc
139
143
  {
140
144
  "sessionAutoName": { "enabled": false, "ghosttyTab": true }, // or boolean shorthand
141
145
  "swordHeader": false, // or { "enabled": true }
142
- "fastMode": false // or { "enabled": true }
146
+ "fastMode": false, // or { "enabled": true }
147
+ "providerStallWatchdog": false // or { "enabled": true }
143
148
  }
144
149
  ```
145
150
 
146
151
  `sessionAutoName.enabled` makes one extra short LLM call per session (once, after the first turn) to title it; `false` (default) makes no model calls. `fastMode` only affects `claude-opus-4-8` 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. 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.
147
152
 
153
+ Recommended explicit retry and watchdog settings:
154
+
155
+ ```json
156
+ {
157
+ "retry": {
158
+ "enabled": true,
159
+ "maxRetries": 3,
160
+ "baseDelayMs": 2000
161
+ },
162
+ "providerStallWatchdog": {
163
+ "enabled": true,
164
+ "warningMs": 120000,
165
+ "recoveryMs": 240000,
166
+ "maxStallRetries": 3
167
+ }
168
+ }
169
+ ```
170
+
171
+ `providerStallWatchdog` is OFF by default and runs only for confirmed human interactive TUI runs. JSON, RPC, print, and subagent runs are excluded by activation, not environment or session lineage. Verified with Pi 0.80.10: each semantic stall is aborted and offered to Pi retry until `maxStallRetries` conversions are used; further stalls stop for manual resubmission. `maxStallRetries` defaults to the layered `retry.maxRetries` (Pi default 3); consecutive stall conversions consume Pi retry attempts without a success reset in between, so keep `maxStallRetries <= retry.maxRetries`. A successful assistant turn resets the stall counter (mirroring Pi's own retry counter). The silence warning and all watchdog notices render as main-window notifications, not the bottom status line. Automatic continuation needs enabled Pi retry with remaining capacity. Disabled, exhausted, or incompatible retry degrades to manual resubmission. Pending steering or follow-ups return to the editor and are excluded from automatic continuation. Invalid merged watchdog config fails closed.
172
+
148
173
  ## Development
149
174
 
150
175
  Deps are peers (`@earendil-works/*`, `@sinclair/typebox`) plus the bundled
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-quiver",
3
- "version": "3.2.0",
4
- "description": "Personal pack of Pi coding-agent extensions: context-safe fetch, doc_to_md PDF/DOCX/PPTX-to-Markdown conversion, session naming, and a themed ASCII startup header.",
3
+ "version": "3.3.1",
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",
7
7
  "type": "module",
@@ -33,6 +33,7 @@
33
33
  "session-name.ts",
34
34
  "sword-header.ts",
35
35
  "fast-mode.ts",
36
+ "provider-stall-watchdog.ts",
36
37
  "extension-config.ts",
37
38
  "scripts/pdf_to_md.py",
38
39
  "types/**/*.d.ts",
@@ -42,7 +43,7 @@
42
43
  "scripts": {
43
44
  "check:agents-core": "node scripts/check-agents-core.mjs",
44
45
  "test": "node --test \"*.test.ts\"",
45
- "typecheck": "npx -y tsc --noEmit --allowImportingTsExtensions --target es2022 --module nodenext --moduleResolution nodenext --strict --skipLibCheck --esModuleInterop --resolveJsonModule --lib es2022 --types node fetch.ts fetch.test.ts doc_to_md.ts doc_to_md.test.ts session-name.ts session-name.test.ts sword-header.ts sword-header.test.ts fast-mode.ts fast-mode.test.ts extension-config.ts types/turndown-plugin-gfm.d.ts",
46
+ "typecheck": "npx -y tsc --noEmit --allowImportingTsExtensions --target es2022 --module nodenext --moduleResolution nodenext --strict --skipLibCheck --esModuleInterop --resolveJsonModule --lib es2022 --types node fetch.ts fetch.test.ts doc_to_md.ts doc_to_md.test.ts session-name.ts session-name.test.ts sword-header.ts sword-header.test.ts fast-mode.ts fast-mode.test.ts provider-stall-watchdog.ts provider-stall-watchdog.test.ts extension-config.ts types/turndown-plugin-gfm.d.ts",
46
47
  "test:all": "npm run check:agents-core && npm run test && npm run typecheck"
47
48
  },
48
49
  "pi": {
@@ -51,7 +52,8 @@
51
52
  "./doc_to_md.ts",
52
53
  "./session-name.ts",
53
54
  "./sword-header.ts",
54
- "./fast-mode.ts"
55
+ "./fast-mode.ts",
56
+ "./provider-stall-watchdog.ts"
55
57
  ],
56
58
  "image": "https://raw.githubusercontent.com/jjuraszek/pi-quiver/main/pi-quiver.png"
57
59
  },
@@ -0,0 +1,268 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { readSettings, resolveConfig, settingsPaths } from "./extension-config.ts";
3
+
4
+ export const MAX_TIMER_MS = 2_147_483_647;
5
+ export const DEFAULT_CONFIG = {
6
+ enabled: false,
7
+ warningMs: 120_000,
8
+ recoveryMs: 240_000,
9
+ } as const;
10
+
11
+ export type WatchdogConfig = {
12
+ enabled: boolean;
13
+ warningMs: number;
14
+ recoveryMs: number;
15
+ maxStallRetries: number;
16
+ };
17
+
18
+ export type WatchdogRuntime = {
19
+ now(): number;
20
+ setTimeout(callback: () => void, delayMs: number): unknown;
21
+ clearTimeout(handle: unknown): void;
22
+ };
23
+
24
+ export type ConfigCandidate = {
25
+ blockIsObject?: unknown;
26
+ enabled?: unknown;
27
+ warningMs?: unknown;
28
+ recoveryMs?: unknown;
29
+ maxStallRetries?: unknown;
30
+ };
31
+
32
+ export type ConfigValidation =
33
+ | { ok: true; config: WatchdogConfig }
34
+ | { ok: false; error: string };
35
+
36
+ const DEFAULT_CANDIDATE: ConfigCandidate = { blockIsObject: true, ...DEFAULT_CONFIG };
37
+
38
+ export function coerce(raw: unknown): ConfigCandidate | undefined {
39
+ if (raw === undefined) return undefined;
40
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return { blockIsObject: false };
41
+
42
+ const source = raw as Record<string, unknown>;
43
+ const candidate: ConfigCandidate = { blockIsObject: true };
44
+ for (const key of ["enabled", "warningMs", "recoveryMs", "maxStallRetries"] as const) {
45
+ if (Object.hasOwn(source, key)) candidate[key] = source[key];
46
+ }
47
+ return candidate;
48
+ }
49
+
50
+ export function validateConfig(candidate: ConfigCandidate): ConfigValidation {
51
+ if (candidate.blockIsObject !== true) return { ok: false, error: "providerStallWatchdog must be an object" };
52
+ if (typeof candidate.enabled !== "boolean") return { ok: false, error: "enabled must be a boolean" };
53
+ if (!isTimerDelay(candidate.warningMs)) return { ok: false, error: "warningMs must be a positive timer delay" };
54
+ if (!isTimerDelay(candidate.recoveryMs)) return { ok: false, error: "recoveryMs must be a positive timer delay" };
55
+ if (candidate.warningMs >= candidate.recoveryMs) return { ok: false, error: "warningMs must be less than recoveryMs" };
56
+ if (!isPositiveInteger(candidate.maxStallRetries)) return { ok: false, error: "maxStallRetries must be a positive integer" };
57
+ return {
58
+ ok: true,
59
+ config: {
60
+ enabled: candidate.enabled,
61
+ warningMs: candidate.warningMs,
62
+ recoveryMs: candidate.recoveryMs,
63
+ maxStallRetries: candidate.maxStallRetries,
64
+ },
65
+ };
66
+ }
67
+
68
+ function isTimerDelay(value: unknown): value is number {
69
+ return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value > 0 && value <= MAX_TIMER_MS;
70
+ }
71
+
72
+ function isPositiveInteger(value: unknown): value is number {
73
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
74
+ }
75
+
76
+ /** Pi's own resolution is `settings.retry?.maxRetries ?? 3` over the same layered settings.json files. */
77
+ export function resolveRetryMaxRetries(cwd: string): number {
78
+ let maxRetries = 3;
79
+ for (const path of settingsPaths(cwd)) {
80
+ const retry = readSettings(path)?.retry;
81
+ if (retry === null || typeof retry !== "object" || Array.isArray(retry)) continue;
82
+ const value = (retry as Record<string, unknown>).maxRetries;
83
+ if (isPositiveInteger(value)) maxRetries = value;
84
+ }
85
+ return maxRetries;
86
+ }
87
+
88
+ export function resolveWatchdogConfig(cwd: string): ConfigValidation {
89
+ const candidate = resolveConfig(cwd, "providerStallWatchdog", DEFAULT_CANDIDATE, coerce);
90
+ if (candidate.blockIsObject === true && candidate.maxStallRetries === undefined) {
91
+ candidate.maxStallRetries = resolveRetryMaxRetries(cwd);
92
+ }
93
+ return validateConfig(candidate);
94
+ }
95
+
96
+ const defaultRuntime: WatchdogRuntime = {
97
+ now: () => Date.now(),
98
+ setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
99
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
100
+ };
101
+
102
+ const DEGRADATION_NOTICE = "The stalled request was stopped, but Pi did not start an automatic retry. Retry may be disabled, exhausted, or incompatible; submit the message again to retry manually.";
103
+
104
+ type Timer = { warning?: unknown; recovery?: unknown };
105
+
106
+ function formatElapsed(ms: number): string {
107
+ if (ms % 60_000 === 0) return `${ms / 60_000}m`;
108
+ if (ms % 1_000 === 0) return `${ms / 1_000}s`;
109
+ return `${ms}ms`;
110
+ }
111
+
112
+ function warningNotice(config: WatchdogConfig): string {
113
+ return `No model progress for ${formatElapsed(config.warningMs)}; aborting and asking Pi to retry in ${formatElapsed(config.recoveryMs - config.warningMs)} (Esc aborts now)`;
114
+ }
115
+
116
+ function exhaustedNotice(config: WatchdogConfig): string {
117
+ return `Stall retry budget (${config.maxStallRetries}) exhausted; aborting without another automatic retry. Submit the message again manually.`;
118
+ }
119
+
120
+ export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRuntime): (pi: ExtensionAPI) => void {
121
+ return (pi) => {
122
+ let pendingInteractive = false;
123
+ let activeRun = false;
124
+ let disabled = false;
125
+ let config: WatchdogConfig | undefined;
126
+ let generation = 0;
127
+ let activeGeneration: number | undefined;
128
+ let lastSemanticAt = 0;
129
+ let warned = false;
130
+ let epoch = 0;
131
+ let deadlineEpoch = 0;
132
+ let timers: Timer = {};
133
+ let removeSignalListener: (() => void) | undefined;
134
+ let ui: { notify(text: string, type?: string): void } | undefined;
135
+ let watchdogAbortedGeneration: number | undefined;
136
+ let timeoutConversionPending = false;
137
+ let stallRetriesUsed = 0;
138
+ let continuationStarted = false;
139
+ let convertedTimeout = false;
140
+
141
+ const clearTimers = () => {
142
+ if (timers.warning !== undefined) runtime.clearTimeout(timers.warning);
143
+ if (timers.recovery !== undefined) runtime.clearTimeout(timers.recovery);
144
+ timers = {};
145
+ };
146
+ const clear = () => {
147
+ clearTimers();
148
+ removeSignalListener?.();
149
+ removeSignalListener = undefined;
150
+ activeGeneration = undefined;
151
+ };
152
+ const disarm = () => { clear(); warned = false; };
153
+ const resetRunState = () => {
154
+ disarm();
155
+ activeRun = false;
156
+ pendingInteractive = false;
157
+ stallRetriesUsed = 0;
158
+ continuationStarted = false;
159
+ convertedTimeout = false;
160
+ watchdogAbortedGeneration = undefined;
161
+ timeoutConversionPending = false;
162
+ };
163
+ const schedule = (ctx: { ui: typeof ui; abort(): void }) => {
164
+ if (activeGeneration === undefined || !config) return;
165
+ const capturedGeneration = activeGeneration;
166
+ const capturedEpoch = epoch;
167
+ const capturedDeadlineEpoch = ++deadlineEpoch;
168
+ const run = (kind: "warning" | "recovery", threshold: number) => () => {
169
+ if (capturedEpoch !== epoch || capturedGeneration !== activeGeneration || capturedDeadlineEpoch !== deadlineEpoch || !activeRun || !config) return;
170
+ const elapsed = runtime.now() - lastSemanticAt;
171
+ if (elapsed < threshold) {
172
+ timers[kind] = runtime.setTimeout(run(kind, threshold), threshold - elapsed);
173
+ return;
174
+ }
175
+ if (kind === "warning" && !warned) {
176
+ warned = true;
177
+ ctx.ui?.notify(warningNotice(config), "warning");
178
+ }
179
+ if (kind === "recovery") {
180
+ clearTimers();
181
+ warned = false;
182
+ if (stallRetriesUsed >= config.maxStallRetries) {
183
+ ui?.notify(exhaustedNotice(config));
184
+ ctx.abort();
185
+ return;
186
+ }
187
+ watchdogAbortedGeneration = capturedGeneration;
188
+ timeoutConversionPending = true;
189
+ stallRetriesUsed += 1;
190
+ ui?.notify(`No model progress for ${formatElapsed(elapsed)}; aborting now. Pi will retry (${stallRetriesUsed}/${config.maxStallRetries}) if retry is enabled and capacity remains. Pending follow-ups are returned to the editor.`);
191
+ ctx.abort();
192
+ }
193
+ };
194
+ timers.warning = runtime.setTimeout(run("warning", config.warningMs), config.warningMs);
195
+ timers.recovery = runtime.setTimeout(run("recovery", config.recoveryMs), config.recoveryMs);
196
+ };
197
+
198
+ pi.on("input", (event) => {
199
+ if (!activeRun) pendingInteractive = event.source === "interactive";
200
+ });
201
+ pi.on("before_agent_start", (_event, ctx) => {
202
+ if (!pendingInteractive) return;
203
+ pendingInteractive = false;
204
+ if (ctx.mode !== "tui" || disabled) return;
205
+ const resolved = resolveWatchdogConfig(ctx.cwd);
206
+ if (!resolved.ok) {
207
+ disabled = true;
208
+ console.warn(`providerStallWatchdog disabled: ${resolved.error}`);
209
+ ctx.ui.notify(`providerStallWatchdog disabled: ${resolved.error}`, "warning");
210
+ return;
211
+ }
212
+ config = resolved.config;
213
+ activeRun = config.enabled;
214
+ });
215
+ pi.on("before_provider_request", (_event, ctx) => {
216
+ if (!activeRun || ctx.mode !== "tui" || !config) return;
217
+ disarm();
218
+ if (convertedTimeout) continuationStarted = true;
219
+ activeGeneration = ++generation;
220
+ lastSemanticAt = runtime.now();
221
+ ui = ctx.ui;
222
+ const target = ctx.signal;
223
+ if (target) {
224
+ const listener = () => {
225
+ if (watchdogAbortedGeneration !== activeGeneration) disarm();
226
+ };
227
+ target.addEventListener("abort", listener, { once: true });
228
+ removeSignalListener = () => target.removeEventListener("abort", listener);
229
+ }
230
+ schedule(ctx);
231
+ });
232
+ pi.on("message_update", (event, ctx) => {
233
+ const update = event.assistantMessageEvent;
234
+ if (activeGeneration === undefined || !(update.type === "text_delta" || update.type === "thinking_delta" || update.type === "toolcall_delta") || update.delta.length === 0) return;
235
+ lastSemanticAt = runtime.now();
236
+ warned = false;
237
+ clearTimers();
238
+ schedule(ctx);
239
+ });
240
+ pi.on("message_end", (event) => {
241
+ if (event.message.role !== "assistant") return;
242
+ const matchesWatchdogAbort = event.message.stopReason === "aborted"
243
+ && activeGeneration === watchdogAbortedGeneration
244
+ && timeoutConversionPending;
245
+ disarm();
246
+ // Mirror Pi's retry loop, which resets its attempt counter on any successful assistant turn.
247
+ if (event.message.stopReason !== "aborted" && event.message.stopReason !== "error") stallRetriesUsed = 0;
248
+ if (!matchesWatchdogAbort || !config) return;
249
+ timeoutConversionPending = false;
250
+ convertedTimeout = true;
251
+ continuationStarted = false;
252
+ return { message: { ...event.message, stopReason: "error", errorMessage: `Provider semantic timeout after ${config.recoveryMs} ms without progress` } };
253
+ });
254
+ pi.on("agent_end", () => disarm());
255
+ pi.on("agent_settled", () => {
256
+ if (convertedTimeout && !continuationStarted) ui?.notify(DEGRADATION_NOTICE);
257
+ resetRunState();
258
+ });
259
+ pi.on("session_shutdown", () => {
260
+ epoch += 1;
261
+ resetRunState();
262
+ config = undefined;
263
+ disabled = false;
264
+ });
265
+ };
266
+ }
267
+
268
+ export default createProviderStallWatchdog();