pi-quiver 3.1.2 → 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 +10 -0
- package/README.md +34 -8
- package/fast-mode.ts +182 -0
- package/package.json +9 -5
- package/provider-stall-watchdog.ts +245 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,16 @@ 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
|
+
|
|
17
|
+
## v3.2.0 - 2026-07-12
|
|
18
|
+
|
|
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).
|
|
20
|
+
|
|
11
21
|
## v3.1.2 - 2026-07-07
|
|
12
22
|
|
|
13
23
|
- **`fetch` routes GitHub Actions run URLs through `gh`.** `github.com/{owner}/{repo}/actions/runs/{id}` URLs are served by `gh run view {id} --repo {owner}/{repo}` and returned through the existing size gate with a `Source: gh run view ...` header, alongside the existing issue/PR/repo routing. Only the bare run URL routes; deeper paths (`.../runs/{id}/jobs/{jobId}`, `.../actions/workflows/{file}`) fall back to HTTP. Falls back silently when `gh` is absent/unauthenticated/errors; `raw=true` forces the rendered page.
|
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
|
|
@@ -66,6 +66,8 @@ A 300 KB changelog page never touches your context window - you get a preview an
|
|
|
66
66
|
| `doc_to_md.ts` | `doc_to_md` | Convert a local PDF/DOCX/PPTX to Markdown. High-fidelity via `pymupdf4llm` (run through `uv`); degraded pure-JS fallback (`unpdf`) when `uv`/Python is unavailable or conversion times out. DOCX/PPTX convert via LibreOffice first. |
|
|
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
|
+
| `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. |
|
|
69
71
|
|
|
70
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).
|
|
71
73
|
|
|
@@ -76,18 +78,21 @@ Full routing rules, size-gate mechanics, and config: [doc/fetch.md](doc/fetch.md
|
|
|
76
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. |
|
|
77
79
|
| Content routing | HTML -> Markdown, binary -> untouched file, GitHub URLs -> `gh` CLI, everything else -> the size gate. |
|
|
78
80
|
| Graceful degradation | Optional binaries (`gh`, `uv`, LibreOffice) are never hard install-time deps; each has a defined, documented fallback or failure mode. |
|
|
79
|
-
| 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. |
|
|
80
83
|
|
|
81
84
|
## When to use
|
|
82
85
|
|
|
83
86
|
- An agent needs to reason from a real web page, GitHub issue/PR, or local PDF/DOCX/PPTX instead of memory.
|
|
84
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.
|
|
85
89
|
|
|
86
90
|
## When NOT to use
|
|
87
91
|
|
|
88
92
|
- You need a general-purpose web scraper (JS-rendered pages, pagination, auth flows) - `fetch` does plain HTTP + Readability extraction, nothing more.
|
|
89
93
|
- You need spreadsheet conversion - `doc_to_md` explicitly excludes spreadsheets (they paginate badly via PDF).
|
|
90
|
-
- 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.
|
|
91
96
|
|
|
92
97
|
## Install
|
|
93
98
|
|
|
@@ -130,18 +135,39 @@ The npm package's bundled JS deps install automatically on `pi install`. A few *
|
|
|
130
135
|
|
|
131
136
|
None is a hard install-time dependency of the package; they are tools you provide in the environment where pi runs.
|
|
132
137
|
|
|
133
|
-
###
|
|
138
|
+
### Opt-in extension config
|
|
134
139
|
|
|
135
|
-
|
|
140
|
+
These extensions are opt-in via `settings.json` (project `.pi/settings.json` overrides the global agent-dir layer):
|
|
136
141
|
|
|
137
142
|
```jsonc
|
|
138
143
|
{
|
|
139
144
|
"sessionAutoName": { "enabled": false, "ghosttyTab": true }, // or boolean shorthand
|
|
140
|
-
"swordHeader": false
|
|
145
|
+
"swordHeader": false, // or { "enabled": true }
|
|
146
|
+
"fastMode": false, // or { "enabled": true }
|
|
147
|
+
"providerStallWatchdog": false // or { "enabled": true }
|
|
141
148
|
}
|
|
142
149
|
```
|
|
143
150
|
|
|
144
|
-
`sessionAutoName.enabled` makes one extra short LLM call per session (once, after the first turn) to title it; `false` (default) makes no model calls. 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.
|
|
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.
|
|
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.
|
|
145
171
|
|
|
146
172
|
## Development
|
|
147
173
|
|
package/fast-mode.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fast mode for Claude Opus 4.8.
|
|
3
|
+
*
|
|
4
|
+
* When enabled, injects Anthropic's fast-mode signals into every qualifying
|
|
5
|
+
* Opus 4.8 request on the anthropic-messages API, regardless of thinking level:
|
|
6
|
+
* - payload: { ...payload, speed: "fast" } (before_provider_request)
|
|
7
|
+
* - header: anthropic-beta: ...,fast-mode-2026-02-01 (before_provider_headers)
|
|
8
|
+
*
|
|
9
|
+
* OFF BY DEFAULT. Three control surfaces, lowest precedence first:
|
|
10
|
+
* 1. settings.json "fastMode": true | { "enabled": true } (default false)
|
|
11
|
+
* 2. --fast launch flag (force-on only)
|
|
12
|
+
* 3. /fast [on|off|status] live toggle (wins for session)
|
|
13
|
+
*
|
|
14
|
+
* Header coupling to pi-ai internals: pi assembles `anthropic-beta` AFTER this
|
|
15
|
+
* hook and merges the hook's headers LAST, so setting the header here REPLACES
|
|
16
|
+
* pi's list. For opus-4-8 pi's conditional betas (fine-grained tool streaming,
|
|
17
|
+
* interleaved thinking) are never applied (eager tool streaming defaults on +
|
|
18
|
+
* forceAdaptiveThinking), so the only betas to preserve are the OAuth identity
|
|
19
|
+
* betas. We detect OAuth via the same token marker pi uses and rebuild the
|
|
20
|
+
* exact list. If pi later adds betas for opus-4-8, revisit buildBetaHeader.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { resolveConfig } from "./extension-config.ts";
|
|
25
|
+
|
|
26
|
+
export const FAST_MODE_BETA = "fast-mode-2026-02-01";
|
|
27
|
+
export const FAST_SPEED = "fast";
|
|
28
|
+
// Loose prefix: matches dated snapshots (claude-opus-4-8-*). Opus 4.7 is out of
|
|
29
|
+
// scope (D1); a future 4.9 needs a one-line addition here.
|
|
30
|
+
export const FAST_MODE_MODEL_PREFIXES = ["claude-opus-4-8"];
|
|
31
|
+
export const OAUTH_IDENTITY_BETAS = ["claude-code-20250219", "oauth-2025-04-20"];
|
|
32
|
+
const STATUS_KEY = "fast-mode";
|
|
33
|
+
const BETA_HEADER = "anthropic-beta";
|
|
34
|
+
|
|
35
|
+
type Config = { enabled: boolean };
|
|
36
|
+
const DEFAULT_CONFIG: Config = { enabled: false };
|
|
37
|
+
|
|
38
|
+
export function coerce(raw: unknown): Partial<Config> | undefined {
|
|
39
|
+
if (raw === undefined) return undefined;
|
|
40
|
+
if (typeof raw === "boolean") return { enabled: raw };
|
|
41
|
+
if (raw && typeof raw === "object") {
|
|
42
|
+
const o = raw as Record<string, unknown>;
|
|
43
|
+
const out: Partial<Config> = {};
|
|
44
|
+
if (typeof o.enabled === "boolean") out.enabled = o.enabled;
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type ModelLike = { id?: string; api?: string; provider?: string } | undefined;
|
|
51
|
+
|
|
52
|
+
export function shouldInject(enabled: boolean, model: ModelLike): boolean {
|
|
53
|
+
if (!enabled || !model) return false;
|
|
54
|
+
if (model.provider !== "anthropic") return false;
|
|
55
|
+
if (model.api !== "anthropic-messages") return false;
|
|
56
|
+
const id = model.id;
|
|
57
|
+
if (typeof id !== "string") return false;
|
|
58
|
+
return FAST_MODE_MODEL_PREFIXES.some((p) => id.startsWith(p));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function injectSpeed(payload: unknown): unknown {
|
|
62
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
63
|
+
return payload;
|
|
64
|
+
}
|
|
65
|
+
return { ...(payload as Record<string, unknown>), speed: FAST_SPEED };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildBetaHeader(existing: string | null | undefined, isOAuth: boolean): string {
|
|
69
|
+
const seen = new Set<string>();
|
|
70
|
+
const out: string[] = [];
|
|
71
|
+
const add = (b: string): void => {
|
|
72
|
+
const t = b.trim();
|
|
73
|
+
if (t && !seen.has(t)) {
|
|
74
|
+
seen.add(t);
|
|
75
|
+
out.push(t);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
if (isOAuth) OAUTH_IDENTITY_BETAS.forEach(add);
|
|
79
|
+
if (typeof existing === "string") existing.split(",").forEach(add);
|
|
80
|
+
add(FAST_MODE_BETA);
|
|
81
|
+
return out.join(",");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type State = { config: boolean; flag: boolean; live: boolean | null };
|
|
85
|
+
|
|
86
|
+
export function resolveEnabled(s: State): boolean {
|
|
87
|
+
if (s.live !== null) return s.live;
|
|
88
|
+
return s.flag || s.config;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export default function (pi: ExtensionAPI) {
|
|
92
|
+
let liveOverride: boolean | null = null;
|
|
93
|
+
let enabled = false;
|
|
94
|
+
|
|
95
|
+
const readFlag = (): boolean => pi.getFlag("fast") === true;
|
|
96
|
+
|
|
97
|
+
const resolveState = (ctx: ExtensionContext): boolean => {
|
|
98
|
+
const config = resolveConfig(ctx.cwd, "fastMode", DEFAULT_CONFIG, coerce).enabled;
|
|
99
|
+
enabled = resolveEnabled({ config, flag: readFlag(), live: liveOverride });
|
|
100
|
+
return enabled;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const refreshStatus = (ctx: ExtensionContext): void => {
|
|
104
|
+
if (!enabled) {
|
|
105
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
ctx.ui.setStatus(STATUS_KEY, shouldInject(enabled, ctx.model) ? "\u26a1 fast" : "\u26a1 n/a");
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const detectOAuth = async (ctx: ExtensionContext): Promise<boolean | null> => {
|
|
112
|
+
if (!ctx.model) return null;
|
|
113
|
+
try {
|
|
114
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
|
|
115
|
+
if (!auth.ok) return null;
|
|
116
|
+
return typeof auth.apiKey === "string" && auth.apiKey.includes("sk-ant-oat");
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
pi.registerFlag("fast", {
|
|
123
|
+
type: "boolean",
|
|
124
|
+
description: "Enable Anthropic fast mode for Opus 4.8 requests this launch",
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
128
|
+
resolveState(ctx);
|
|
129
|
+
refreshStatus(ctx);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
pi.on("model_select", async (_event, ctx) => {
|
|
133
|
+
refreshStatus(ctx);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
137
|
+
if (!shouldInject(enabled, ctx.model)) return;
|
|
138
|
+
return injectSpeed(event.payload);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
pi.on("before_provider_headers", async (event, ctx) => {
|
|
142
|
+
if (!shouldInject(enabled, ctx.model)) return;
|
|
143
|
+
if (!event.headers) return;
|
|
144
|
+
const isOAuth = await detectOAuth(ctx);
|
|
145
|
+
if (isOAuth === null) return;
|
|
146
|
+
event.headers[BETA_HEADER] = buildBetaHeader(event.headers[BETA_HEADER], isOAuth);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
pi.registerCommand("fast", {
|
|
150
|
+
description: "Manage Opus 4.8 fast mode: /fast [on|off|status]",
|
|
151
|
+
getArgumentCompletions: (prefix) => {
|
|
152
|
+
const p = prefix.trim().toLowerCase();
|
|
153
|
+
if (p.includes(" ")) return null;
|
|
154
|
+
const matches = ["on", "off", "status"].filter((v) => v.startsWith(p));
|
|
155
|
+
return matches.length ? matches.map((value) => ({ value, label: value })) : null;
|
|
156
|
+
},
|
|
157
|
+
handler: async (args, ctx) => {
|
|
158
|
+
const arg = args.trim().toLowerCase();
|
|
159
|
+
if (arg === "status") {
|
|
160
|
+
const eff = liveOverride !== null ? "live toggle" : readFlag() ? "--fast flag" : "settings.json";
|
|
161
|
+
const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "(no model selected)";
|
|
162
|
+
const applies = shouldInject(enabled, ctx.model) ? "applies to current model" : "does not apply to current model";
|
|
163
|
+
ctx.ui.notify(
|
|
164
|
+
`Fast mode is ${enabled ? "on" : "off"} (source: ${eff}). Model: ${model} - ${applies}.`,
|
|
165
|
+
"info",
|
|
166
|
+
);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (arg === "on" || arg === "off") {
|
|
170
|
+
liveOverride = arg === "on";
|
|
171
|
+
} else if (arg === "") {
|
|
172
|
+
liveOverride = !enabled;
|
|
173
|
+
} else {
|
|
174
|
+
ctx.ui.notify("Usage: /fast [on|off|status]", "warning");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
resolveState(ctx);
|
|
178
|
+
refreshStatus(ctx);
|
|
179
|
+
ctx.ui.notify(`Fast mode ${enabled ? "enabled" : "disabled"}`, "info");
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
}
|
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",
|
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
"doc_to_md.ts",
|
|
33
33
|
"session-name.ts",
|
|
34
34
|
"sword-header.ts",
|
|
35
|
+
"fast-mode.ts",
|
|
36
|
+
"provider-stall-watchdog.ts",
|
|
35
37
|
"extension-config.ts",
|
|
36
38
|
"scripts/pdf_to_md.py",
|
|
37
39
|
"types/**/*.d.ts",
|
|
@@ -41,7 +43,7 @@
|
|
|
41
43
|
"scripts": {
|
|
42
44
|
"check:agents-core": "node scripts/check-agents-core.mjs",
|
|
43
45
|
"test": "node --test \"*.test.ts\"",
|
|
44
|
-
"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 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",
|
|
45
47
|
"test:all": "npm run check:agents-core && npm run test && npm run typecheck"
|
|
46
48
|
},
|
|
47
49
|
"pi": {
|
|
@@ -49,7 +51,9 @@
|
|
|
49
51
|
"./fetch.ts",
|
|
50
52
|
"./doc_to_md.ts",
|
|
51
53
|
"./session-name.ts",
|
|
52
|
-
"./sword-header.ts"
|
|
54
|
+
"./sword-header.ts",
|
|
55
|
+
"./fast-mode.ts",
|
|
56
|
+
"./provider-stall-watchdog.ts"
|
|
53
57
|
],
|
|
54
58
|
"image": "https://raw.githubusercontent.com/jjuraszek/pi-quiver/main/pi-quiver.png"
|
|
55
59
|
},
|
|
@@ -86,7 +90,7 @@
|
|
|
86
90
|
},
|
|
87
91
|
"devDependencies": {
|
|
88
92
|
"@earendil-works/pi-ai": "^0.80.3",
|
|
89
|
-
"@earendil-works/pi-coding-agent": "^0.80.
|
|
93
|
+
"@earendil-works/pi-coding-agent": "^0.80.5",
|
|
90
94
|
"@earendil-works/pi-tui": "^0.80.3",
|
|
91
95
|
"@sinclair/typebox": "^0.34.49",
|
|
92
96
|
"@types/jsdom": "^28.0.3",
|
|
@@ -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();
|