pi-quiver 3.2.0 → 3.3.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 +6 -0
- package/README.md +30 -6
- package/package.json +6 -4
- package/provider-stall-watchdog.ts +245 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,12 @@ 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.0 - 2026-07-17
|
|
12
|
+
|
|
13
|
+
- **`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.
|
|
14
|
+
- **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.
|
|
15
|
+
- **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.
|
|
16
|
+
|
|
11
17
|
## v3.2.0 - 2026-07-12
|
|
12
18
|
|
|
13
19
|
- 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 `
|
|
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.
|
|
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 the first stall once to Pi's retry loop, then stops a second stall. 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
|
|
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
|
|
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,38 @@ 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
|
-
|
|
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
|
|
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
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`providerStallWatchdog` is OFF by default and runs only for confirmed human interactive TUI runs. JSON, RPC, print, and subagent runs are excluded by activation, not environment or session lineage. Verified with Pi 0.80.10: the first semantic stall is aborted and offered once to Pi retry; a second stall stops. Automatic continuation needs enabled Pi retry with remaining capacity. Disabled, exhausted, or incompatible retry degrades to manual resubmission. Pending steering or follow-ups return to the editor and are excluded from automatic continuation. Invalid merged watchdog config fails closed.
|
|
171
|
+
|
|
148
172
|
## Development
|
|
149
173
|
|
|
150
174
|
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.
|
|
4
|
-
"description": "Personal pack of Pi coding-agent extensions: context-safe fetch, doc_to_md PDF/DOCX/PPTX-to-Markdown conversion, session naming,
|
|
3
|
+
"version": "3.3.0",
|
|
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,245 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { resolveConfig } 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
|
+
};
|
|
16
|
+
|
|
17
|
+
export type WatchdogRuntime = {
|
|
18
|
+
now(): number;
|
|
19
|
+
setTimeout(callback: () => void, delayMs: number): unknown;
|
|
20
|
+
clearTimeout(handle: unknown): void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ConfigCandidate = {
|
|
24
|
+
blockIsObject?: unknown;
|
|
25
|
+
enabled?: unknown;
|
|
26
|
+
warningMs?: unknown;
|
|
27
|
+
recoveryMs?: unknown;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ConfigValidation =
|
|
31
|
+
| { ok: true; config: WatchdogConfig }
|
|
32
|
+
| { ok: false; error: string };
|
|
33
|
+
|
|
34
|
+
const DEFAULT_CANDIDATE: ConfigCandidate = { blockIsObject: true, ...DEFAULT_CONFIG };
|
|
35
|
+
|
|
36
|
+
export function coerce(raw: unknown): ConfigCandidate | undefined {
|
|
37
|
+
if (raw === undefined) return undefined;
|
|
38
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return { blockIsObject: false };
|
|
39
|
+
|
|
40
|
+
const source = raw as Record<string, unknown>;
|
|
41
|
+
const candidate: ConfigCandidate = { blockIsObject: true };
|
|
42
|
+
for (const key of ["enabled", "warningMs", "recoveryMs"] as const) {
|
|
43
|
+
if (Object.hasOwn(source, key)) candidate[key] = source[key];
|
|
44
|
+
}
|
|
45
|
+
return candidate;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function validateConfig(candidate: ConfigCandidate): ConfigValidation {
|
|
49
|
+
if (candidate.blockIsObject !== true) return { ok: false, error: "providerStallWatchdog must be an object" };
|
|
50
|
+
if (typeof candidate.enabled !== "boolean") return { ok: false, error: "enabled must be a boolean" };
|
|
51
|
+
if (!isTimerDelay(candidate.warningMs)) return { ok: false, error: "warningMs must be a positive timer delay" };
|
|
52
|
+
if (!isTimerDelay(candidate.recoveryMs)) return { ok: false, error: "recoveryMs must be a positive timer delay" };
|
|
53
|
+
if (candidate.warningMs >= candidate.recoveryMs) return { ok: false, error: "warningMs must be less than recoveryMs" };
|
|
54
|
+
return {
|
|
55
|
+
ok: true,
|
|
56
|
+
config: {
|
|
57
|
+
enabled: candidate.enabled,
|
|
58
|
+
warningMs: candidate.warningMs,
|
|
59
|
+
recoveryMs: candidate.recoveryMs,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isTimerDelay(value: unknown): value is number {
|
|
65
|
+
return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value > 0 && value <= MAX_TIMER_MS;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function resolveWatchdogConfig(cwd: string): ConfigValidation {
|
|
69
|
+
return validateConfig(resolveConfig(cwd, "providerStallWatchdog", DEFAULT_CANDIDATE, coerce));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const defaultRuntime: WatchdogRuntime = {
|
|
73
|
+
now: () => Date.now(),
|
|
74
|
+
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
75
|
+
clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const STATUS_KEY = "providerStallWatchdog";
|
|
79
|
+
const SECOND_STALL_NOTICE = "The retry also stopped making progress; aborting without another automatic retry. Submit the message again manually.";
|
|
80
|
+
const DEGRADATION_NOTICE = "The stalled request was stopped, but Pi did not start an automatic retry. Retry may be disabled, exhausted, or incompatible; submit the message again to retry manually.";
|
|
81
|
+
|
|
82
|
+
type Timer = { warning?: unknown; recovery?: unknown };
|
|
83
|
+
|
|
84
|
+
function formatElapsed(ms: number): string {
|
|
85
|
+
if (ms % 60_000 === 0) return `${ms / 60_000}m`;
|
|
86
|
+
if (ms % 1_000 === 0) return `${ms / 1_000}s`;
|
|
87
|
+
return `${ms}ms`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function warningStatus(config: WatchdogConfig): string {
|
|
91
|
+
return `No model progress for ${formatElapsed(config.warningMs)}; aborting and asking Pi to retry once in ${formatElapsed(config.recoveryMs - config.warningMs)} (Esc aborts now)`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createProviderStallWatchdog(runtime: WatchdogRuntime = defaultRuntime): (pi: ExtensionAPI) => void {
|
|
95
|
+
return (pi) => {
|
|
96
|
+
let pendingInteractive = false;
|
|
97
|
+
let activeRun = false;
|
|
98
|
+
let disabled = false;
|
|
99
|
+
let config: WatchdogConfig | undefined;
|
|
100
|
+
let generation = 0;
|
|
101
|
+
let activeGeneration: number | undefined;
|
|
102
|
+
let lastSemanticAt = 0;
|
|
103
|
+
let warned = false;
|
|
104
|
+
let epoch = 0;
|
|
105
|
+
let deadlineEpoch = 0;
|
|
106
|
+
let timers: Timer = {};
|
|
107
|
+
let removeSignalListener: (() => void) | undefined;
|
|
108
|
+
let ui: { setStatus(key: string, text: string | undefined): void; notify(text: string, type?: string): void } | undefined;
|
|
109
|
+
let watchdogAbortedGeneration: number | undefined;
|
|
110
|
+
let timeoutConversionPending = false;
|
|
111
|
+
let stallRetryConsumed = false;
|
|
112
|
+
let continuationStarted = false;
|
|
113
|
+
let convertedTimeout = false;
|
|
114
|
+
|
|
115
|
+
const clearTimers = () => {
|
|
116
|
+
if (timers.warning !== undefined) runtime.clearTimeout(timers.warning);
|
|
117
|
+
if (timers.recovery !== undefined) runtime.clearTimeout(timers.recovery);
|
|
118
|
+
timers = {};
|
|
119
|
+
};
|
|
120
|
+
const clearDeadlines = () => {
|
|
121
|
+
clearTimers();
|
|
122
|
+
ui?.setStatus(STATUS_KEY, undefined);
|
|
123
|
+
};
|
|
124
|
+
const clear = () => {
|
|
125
|
+
clearDeadlines();
|
|
126
|
+
removeSignalListener?.();
|
|
127
|
+
removeSignalListener = undefined;
|
|
128
|
+
activeGeneration = undefined;
|
|
129
|
+
};
|
|
130
|
+
const disarm = () => { clear(); warned = false; };
|
|
131
|
+
const resetRunState = () => {
|
|
132
|
+
disarm();
|
|
133
|
+
activeRun = false;
|
|
134
|
+
pendingInteractive = false;
|
|
135
|
+
stallRetryConsumed = false;
|
|
136
|
+
continuationStarted = false;
|
|
137
|
+
convertedTimeout = false;
|
|
138
|
+
watchdogAbortedGeneration = undefined;
|
|
139
|
+
timeoutConversionPending = false;
|
|
140
|
+
};
|
|
141
|
+
const schedule = (ctx: { ui: typeof ui; abort(): void }) => {
|
|
142
|
+
if (activeGeneration === undefined || !config) return;
|
|
143
|
+
const capturedGeneration = activeGeneration;
|
|
144
|
+
const capturedEpoch = epoch;
|
|
145
|
+
const capturedDeadlineEpoch = ++deadlineEpoch;
|
|
146
|
+
const run = (kind: "warning" | "recovery", threshold: number) => () => {
|
|
147
|
+
if (capturedEpoch !== epoch || capturedGeneration !== activeGeneration || capturedDeadlineEpoch !== deadlineEpoch || !activeRun || !config) return;
|
|
148
|
+
const elapsed = runtime.now() - lastSemanticAt;
|
|
149
|
+
if (elapsed < threshold) {
|
|
150
|
+
timers[kind] = runtime.setTimeout(run(kind, threshold), threshold - elapsed);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (kind === "warning" && !warned) {
|
|
154
|
+
warned = true;
|
|
155
|
+
ctx.ui?.setStatus(STATUS_KEY, warningStatus(config));
|
|
156
|
+
}
|
|
157
|
+
if (kind === "recovery") {
|
|
158
|
+
clearDeadlines();
|
|
159
|
+
warned = false;
|
|
160
|
+
if (stallRetryConsumed) {
|
|
161
|
+
ui?.notify(SECOND_STALL_NOTICE);
|
|
162
|
+
ctx.abort();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
watchdogAbortedGeneration = capturedGeneration;
|
|
166
|
+
timeoutConversionPending = true;
|
|
167
|
+
stallRetryConsumed = true;
|
|
168
|
+
ui?.notify(`No model progress for ${formatElapsed(elapsed)}; aborting now. Pi will retry once if retry is enabled and capacity remains. Pending follow-ups are returned to the editor.`);
|
|
169
|
+
ctx.abort();
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
timers.warning = runtime.setTimeout(run("warning", config.warningMs), config.warningMs);
|
|
173
|
+
timers.recovery = runtime.setTimeout(run("recovery", config.recoveryMs), config.recoveryMs);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
pi.on("input", (event) => {
|
|
177
|
+
if (!activeRun) pendingInteractive = event.source === "interactive";
|
|
178
|
+
});
|
|
179
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
180
|
+
if (!pendingInteractive) return;
|
|
181
|
+
pendingInteractive = false;
|
|
182
|
+
if (ctx.mode !== "tui" || disabled) return;
|
|
183
|
+
const resolved = resolveWatchdogConfig(ctx.cwd);
|
|
184
|
+
if (!resolved.ok) {
|
|
185
|
+
disabled = true;
|
|
186
|
+
console.warn(`providerStallWatchdog disabled: ${resolved.error}`);
|
|
187
|
+
ctx.ui.notify(`providerStallWatchdog disabled: ${resolved.error}`, "warning");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
config = resolved.config;
|
|
191
|
+
activeRun = config.enabled;
|
|
192
|
+
});
|
|
193
|
+
pi.on("before_provider_request", (_event, ctx) => {
|
|
194
|
+
if (!activeRun || ctx.mode !== "tui" || !config) return;
|
|
195
|
+
disarm();
|
|
196
|
+
if (convertedTimeout) continuationStarted = true;
|
|
197
|
+
activeGeneration = ++generation;
|
|
198
|
+
lastSemanticAt = runtime.now();
|
|
199
|
+
ui = ctx.ui;
|
|
200
|
+
const target = ctx.signal;
|
|
201
|
+
if (target) {
|
|
202
|
+
const listener = () => {
|
|
203
|
+
if (watchdogAbortedGeneration !== activeGeneration) disarm();
|
|
204
|
+
};
|
|
205
|
+
target.addEventListener("abort", listener, { once: true });
|
|
206
|
+
removeSignalListener = () => target.removeEventListener("abort", listener);
|
|
207
|
+
}
|
|
208
|
+
schedule(ctx);
|
|
209
|
+
});
|
|
210
|
+
pi.on("message_update", (event, ctx) => {
|
|
211
|
+
const update = event.assistantMessageEvent;
|
|
212
|
+
if (activeGeneration === undefined || !(update.type === "text_delta" || update.type === "thinking_delta" || update.type === "toolcall_delta") || update.delta.length === 0) return;
|
|
213
|
+
lastSemanticAt = runtime.now();
|
|
214
|
+
warned = false;
|
|
215
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
216
|
+
clearTimers();
|
|
217
|
+
schedule(ctx);
|
|
218
|
+
});
|
|
219
|
+
pi.on("message_end", (event) => {
|
|
220
|
+
if (event.message.role !== "assistant") return;
|
|
221
|
+
const matchesWatchdogAbort = event.message.stopReason === "aborted"
|
|
222
|
+
&& activeGeneration === watchdogAbortedGeneration
|
|
223
|
+
&& timeoutConversionPending
|
|
224
|
+
&& stallRetryConsumed;
|
|
225
|
+
disarm();
|
|
226
|
+
if (!matchesWatchdogAbort || !config) return;
|
|
227
|
+
timeoutConversionPending = false;
|
|
228
|
+
convertedTimeout = true;
|
|
229
|
+
return { message: { ...event.message, stopReason: "error", errorMessage: `Provider semantic timeout after ${config.recoveryMs} ms without progress` } };
|
|
230
|
+
});
|
|
231
|
+
pi.on("agent_end", () => disarm());
|
|
232
|
+
pi.on("agent_settled", () => {
|
|
233
|
+
if (convertedTimeout && !continuationStarted) ui?.notify(DEGRADATION_NOTICE);
|
|
234
|
+
resetRunState();
|
|
235
|
+
});
|
|
236
|
+
pi.on("session_shutdown", () => {
|
|
237
|
+
epoch += 1;
|
|
238
|
+
resetRunState();
|
|
239
|
+
config = undefined;
|
|
240
|
+
disabled = false;
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export default createProviderStallWatchdog();
|