pi-hodor 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vuri
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,173 @@
1
+ # pi-hodor
2
+
3
+ [![npm version](https://img.shields.io/npm/v/pi-hodor)](https://www.npmjs.com/package/pi-hodor)
4
+ [![npm downloads](https://img.shields.io/npm/dm/pi-hodor)](https://www.npmjs.com/package/pi-hodor)
5
+ [![license](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
6
+
7
+ `pi-hodor` is a pi extension that automatically sends a follow-up retry message when an assistant response fails because of transient streaming or connection errors.
8
+
9
+ It is useful when model output is interrupted by provider-side failures such as `ECONNRESET`, `ETIMEDOUT`, premature stream closure, or partial JSON responses. Instead of stopping and waiting for manual intervention, the extension detects the failure and sends a configurable retry message such as `continue`.
10
+
11
+ ## Features
12
+
13
+ - Watches assistant messages that end with `stopReason === "error"`
14
+ - Matches the error text against configurable substring patterns
15
+ - Automatically sends a retry message when a match is found
16
+ - Prevents runaway loops with a configurable retry limit
17
+ - Optionally shows UI notifications when an auto-retry happens
18
+ - Supports project-level config overrides without modifying the packaged files
19
+
20
+ ## Installation
21
+
22
+ Install from npm:
23
+
24
+ ```bash
25
+ pi install npm:pi-hodor
26
+ ```
27
+
28
+ Or from git:
29
+
30
+ ```bash
31
+ pi install git:github.com/vurihuang/pi-hodor
32
+ ```
33
+
34
+ Restart pi after installation so the extension is loaded.
35
+
36
+ ### Load it for a single run
37
+
38
+ ```bash
39
+ pi -e npm:pi-hodor
40
+ ```
41
+
42
+ ### Install from a local path
43
+
44
+ ```bash
45
+ pi install /absolute/path/to/pi-hodor
46
+ ```
47
+
48
+ ### Load from a local path for one session
49
+
50
+ ```bash
51
+ pi -e /absolute/path/to/pi-hodor
52
+ ```
53
+
54
+ ## Verify installation
55
+
56
+ After restarting pi, the extension is active automatically.
57
+
58
+ You can confirm it is loaded by triggering a transient stream error during normal use, or by checking that the extension has been installed from npm and is available in your pi package list.
59
+
60
+ ## Usage
61
+
62
+ Once the extension is loaded, there is nothing else to trigger manually.
63
+
64
+ When pi receives an assistant message that:
65
+
66
+ 1. ends with an error stop reason, and
67
+ 2. contains one of the configured error patterns,
68
+
69
+ `pi-hodor` automatically sends the configured retry message.
70
+
71
+ The default retry message is:
72
+
73
+ ```json
74
+ "continue"
75
+ ```
76
+
77
+ ## Configuration
78
+
79
+ Configuration is resolved in this order:
80
+
81
+ 1. `./.pi-hodor.json`
82
+ 2. `./.pi/pi-hodor.json`
83
+ 3. the bundled `config.json` inside this package
84
+
85
+ This keeps the package defaults intact while allowing per-project overrides.
86
+
87
+ ### Example config
88
+
89
+ ```json
90
+ {
91
+ "enabled": true,
92
+ "retryMessage": "continue",
93
+ "maxConsecutiveAutoRetries": 99,
94
+ "notifyOnAutoContinue": true,
95
+ "errorPatterns": [
96
+ "error decoding response body",
97
+ "stream disconnected before completion",
98
+ "ECONNRESET"
99
+ ]
100
+ }
101
+ ```
102
+
103
+ ### Config fields
104
+
105
+ | Field | Type | Description |
106
+ | --- | --- | --- |
107
+ | `enabled` | `boolean` | Enables or disables the extension logic. |
108
+ | `retryMessage` | `string` | The exact user message sent back to pi after a matched error. |
109
+ | `maxConsecutiveAutoRetries` | `number` | Maximum automatic retries before the extension stops retrying. |
110
+ | `notifyOnAutoContinue` | `boolean` | Shows a UI notification when an automatic retry happens or when the retry limit is reached. |
111
+ | `errorPatterns` | `string[]` | Case-insensitive substrings used to detect transient failures. |
112
+
113
+ ## Development
114
+
115
+ Install dependencies:
116
+
117
+ ```bash
118
+ npm install
119
+ ```
120
+
121
+ Run the type check:
122
+
123
+ ```bash
124
+ npm run check
125
+ ```
126
+
127
+ Preview the npm package contents:
128
+
129
+ ```bash
130
+ npm run pack:check
131
+ ```
132
+
133
+ ## Package structure
134
+
135
+ ```text
136
+ .
137
+ ├── config.json
138
+ ├── index.ts
139
+ ├── LICENSE
140
+ ├── package.json
141
+ ├── README.md
142
+ └── tsconfig.json
143
+ ```
144
+
145
+ ## Updating
146
+
147
+ Reinstall the package from npm:
148
+
149
+ ```bash
150
+ pi install npm:pi-hodor
151
+ ```
152
+
153
+ Or update from git:
154
+
155
+ ```bash
156
+ pi install git:github.com/vurihuang/pi-hodor
157
+ ```
158
+
159
+ Restart pi after updating.
160
+
161
+ ## Install as a pi package
162
+
163
+ This project is already structured as a pi package via the `pi` field in `package.json`:
164
+
165
+ ```json
166
+ {
167
+ "pi": {
168
+ "extensions": ["./index.ts"]
169
+ }
170
+ }
171
+ ```
172
+
173
+ That means pi can install it from a local path, npm, or git using the standard pi package flow.
package/config.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "enabled": true,
3
+ "retryMessage": "continue",
4
+ "maxConsecutiveAutoRetries": 99,
5
+ "notifyOnAutoContinue": true,
6
+ "errorPatterns": [
7
+ "error decoding response body",
8
+ "stream disconnected before completion",
9
+ "stream closed before",
10
+ "stream closed unexpectedly",
11
+ "stream interrupted",
12
+ "stream ended unexpectedly",
13
+ "premature close",
14
+ "socket hang up",
15
+ "connection reset by peer",
16
+ "connection reset",
17
+ "read ECONNRESET",
18
+ "ECONNRESET",
19
+ "ETIMEDOUT",
20
+ "fetch failed",
21
+ "unexpected end of JSON input",
22
+ "unexpected end of input"
23
+ ]
24
+ }
package/index.ts ADDED
@@ -0,0 +1,229 @@
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+
5
+ type NotifyLevel = "info" | "success" | "warning" | "error";
6
+
7
+ type NotifierContext = {
8
+ hasUI: boolean;
9
+ ui: {
10
+ notify(message: string, level: NotifyLevel): void;
11
+ };
12
+ };
13
+
14
+ type QueueAwareContext = NotifierContext & {
15
+ cwd: string;
16
+ isIdle(): boolean;
17
+ hasPendingMessages(): boolean;
18
+ };
19
+
20
+ interface AutoContinueConfig {
21
+ enabled: boolean;
22
+ retryMessage: string;
23
+ maxConsecutiveAutoRetries: number;
24
+ notifyOnAutoContinue: boolean;
25
+ errorPatterns: string[];
26
+ }
27
+
28
+ const EXTENSION_NAME = "pi-hodor";
29
+ const BUNDLED_CONFIG_PATH = join(__dirname, "config.json");
30
+ const PROJECT_CONFIG_CANDIDATES = [
31
+ ".pi-hodor.json",
32
+ join(".pi", "pi-hodor.json"),
33
+ ] as const;
34
+ const DEFAULT_CONFIG: AutoContinueConfig = {
35
+ enabled: true,
36
+ retryMessage: "continue",
37
+ maxConsecutiveAutoRetries: 99,
38
+ notifyOnAutoContinue: true,
39
+ errorPatterns: [
40
+ "上游流式响应中断",
41
+ "error decoding response body",
42
+ "stream disconnected before completion",
43
+ "stream closed before",
44
+ "stream closed unexpectedly",
45
+ "stream interrupted",
46
+ "stream ended unexpectedly",
47
+ "premature close",
48
+ "socket hang up",
49
+ "connection reset by peer",
50
+ "connection reset",
51
+ "read ECONNRESET",
52
+ "ECONNRESET",
53
+ "ETIMEDOUT",
54
+ "fetch failed",
55
+ "unexpected end of JSON input",
56
+ "unexpected end of input",
57
+ ],
58
+ };
59
+
60
+ function isRecord(value: unknown): value is Record<string, unknown> {
61
+ return typeof value === "object" && value !== null;
62
+ }
63
+
64
+ function normalizeConfig(raw: unknown): AutoContinueConfig {
65
+ const config = isRecord(raw) ? raw : {};
66
+ const errorPatterns = Array.isArray(config.errorPatterns)
67
+ ? config.errorPatterns
68
+ .filter((pattern): pattern is string => typeof pattern === "string")
69
+ .map((pattern) => pattern.trim())
70
+ .filter(Boolean)
71
+ : DEFAULT_CONFIG.errorPatterns;
72
+ const retryMessage =
73
+ typeof config.retryMessage === "string" && config.retryMessage.trim().length > 0
74
+ ? config.retryMessage.trim()
75
+ : DEFAULT_CONFIG.retryMessage;
76
+ const maxConsecutiveAutoRetries =
77
+ typeof config.maxConsecutiveAutoRetries === "number" && Number.isFinite(config.maxConsecutiveAutoRetries)
78
+ ? Math.max(0, Math.floor(config.maxConsecutiveAutoRetries))
79
+ : DEFAULT_CONFIG.maxConsecutiveAutoRetries;
80
+
81
+ return {
82
+ enabled: typeof config.enabled === "boolean" ? config.enabled : DEFAULT_CONFIG.enabled,
83
+ retryMessage,
84
+ maxConsecutiveAutoRetries,
85
+ notifyOnAutoContinue:
86
+ typeof config.notifyOnAutoContinue === "boolean"
87
+ ? config.notifyOnAutoContinue
88
+ : DEFAULT_CONFIG.notifyOnAutoContinue,
89
+ errorPatterns: errorPatterns.length > 0 ? errorPatterns : DEFAULT_CONFIG.errorPatterns,
90
+ };
91
+ }
92
+
93
+ function safeNotify(ctx: NotifierContext, message: string, level: NotifyLevel) {
94
+ if (!ctx.hasUI) return;
95
+ ctx.ui.notify(message, level);
96
+ }
97
+
98
+ async function ensureBundledConfigFile() {
99
+ try {
100
+ await access(BUNDLED_CONFIG_PATH);
101
+ } catch {
102
+ await writeFile(BUNDLED_CONFIG_PATH, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, "utf8");
103
+ }
104
+ }
105
+
106
+ async function resolveConfigPath(cwd: string) {
107
+ for (const relativePath of PROJECT_CONFIG_CANDIDATES) {
108
+ const candidatePath = join(cwd, relativePath);
109
+ try {
110
+ await access(candidatePath);
111
+ return candidatePath;
112
+ } catch {
113
+ // Keep searching.
114
+ }
115
+ }
116
+
117
+ return BUNDLED_CONFIG_PATH;
118
+ }
119
+
120
+ async function loadConfig(ctx: QueueAwareContext, lastConfigError: { value?: string }) {
121
+ await ensureBundledConfigFile();
122
+ const configPath = await resolveConfigPath(ctx.cwd);
123
+
124
+ try {
125
+ const config = normalizeConfig(JSON.parse(await readFile(configPath, "utf8")));
126
+ lastConfigError.value = undefined;
127
+ return config;
128
+ } catch (error) {
129
+ const message = error instanceof Error ? error.message : String(error);
130
+ const errorKey = `${configPath}:${message}`;
131
+ if (lastConfigError.value !== errorKey) {
132
+ lastConfigError.value = errorKey;
133
+ safeNotify(
134
+ ctx,
135
+ `[${EXTENSION_NAME}] Failed to read config from ${configPath}. Falling back to defaults: ${message}`,
136
+ "warning",
137
+ );
138
+ }
139
+ return DEFAULT_CONFIG;
140
+ }
141
+ }
142
+
143
+ function extractTextBlocks(content: unknown): string {
144
+ if (!Array.isArray(content)) return "";
145
+ return content
146
+ .flatMap((block) => {
147
+ if (!isRecord(block)) return [];
148
+ if (block.type !== "text") return [];
149
+ return typeof block.text === "string" ? [block.text] : [];
150
+ })
151
+ .join("\n")
152
+ .trim();
153
+ }
154
+
155
+ function extractUserText(content: unknown): string {
156
+ if (typeof content === "string") return content.trim();
157
+ return extractTextBlocks(content);
158
+ }
159
+
160
+ function matchesConfiguredError(errorText: string, patterns: string[]) {
161
+ const normalizedError = errorText.toLowerCase();
162
+ return patterns.some((pattern) => normalizedError.includes(pattern.toLowerCase()));
163
+ }
164
+
165
+ export default function (pi: ExtensionAPI) {
166
+ let consecutiveAutoRetries = 0;
167
+ let pendingAutoRetryMessage: string | undefined;
168
+ const lastConfigError: { value?: string } = {};
169
+
170
+ pi.on("session_start", async () => {
171
+ await ensureBundledConfigFile();
172
+ });
173
+
174
+ pi.on("message_end", async (event, ctx) => {
175
+ if (event.message.role === "user") {
176
+ const userText = extractUserText(event.message.content);
177
+ if (pendingAutoRetryMessage && userText === pendingAutoRetryMessage) {
178
+ pendingAutoRetryMessage = undefined;
179
+ return;
180
+ }
181
+ consecutiveAutoRetries = 0;
182
+ pendingAutoRetryMessage = undefined;
183
+ return;
184
+ }
185
+
186
+ if (event.message.role !== "assistant") return;
187
+
188
+ if (event.message.stopReason !== "error") {
189
+ consecutiveAutoRetries = 0;
190
+ pendingAutoRetryMessage = undefined;
191
+ return;
192
+ }
193
+
194
+ const config = await loadConfig(ctx as QueueAwareContext, lastConfigError);
195
+ if (!config.enabled) return;
196
+ if (ctx.hasPendingMessages()) return;
197
+ if (consecutiveAutoRetries >= config.maxConsecutiveAutoRetries) {
198
+ if (config.notifyOnAutoContinue) {
199
+ safeNotify(
200
+ ctx as QueueAwareContext,
201
+ `[${EXTENSION_NAME}] Reached the consecutive auto-retry limit (${config.maxConsecutiveAutoRetries}). Skipping automatic \"${config.retryMessage}\".`,
202
+ "warning",
203
+ );
204
+ }
205
+ return;
206
+ }
207
+
208
+ const errorText = [event.message.errorMessage, extractTextBlocks(event.message.content)]
209
+ .filter((value): value is string => typeof value === "string" && value.trim().length > 0)
210
+ .join("\n");
211
+ if (!errorText || !matchesConfiguredError(errorText, config.errorPatterns)) return;
212
+
213
+ consecutiveAutoRetries += 1;
214
+ pendingAutoRetryMessage = config.retryMessage;
215
+ if (config.notifyOnAutoContinue) {
216
+ safeNotify(
217
+ ctx as QueueAwareContext,
218
+ `[${EXTENSION_NAME}] Matched a configured error. Sending \"${config.retryMessage}\" automatically (${consecutiveAutoRetries}/${config.maxConsecutiveAutoRetries}).`,
219
+ "info",
220
+ );
221
+ }
222
+
223
+ if (ctx.isIdle()) {
224
+ await pi.sendUserMessage(config.retryMessage);
225
+ } else {
226
+ await pi.sendUserMessage(config.retryMessage, { deliverAs: "followUp" });
227
+ }
228
+ });
229
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "pi-hodor",
3
+ "version": "0.1.0",
4
+ "description": "A pi extension that automatically continues after transient stream and connection errors.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "pi",
10
+ "extension",
11
+ "retry",
12
+ "error-recovery"
13
+ ],
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/vurihuang/pi-hodor.git"
18
+ },
19
+ "homepage": "https://github.com/vurihuang/pi-hodor#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/vurihuang/pi-hodor/issues"
22
+ },
23
+ "files": [
24
+ "index.ts",
25
+ "config.json",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "scripts": {
30
+ "check": "tsc --noEmit",
31
+ "pack:check": "npm pack --dry-run"
32
+ },
33
+ "pi": {
34
+ "extensions": [
35
+ "./index.ts"
36
+ ]
37
+ },
38
+ "peerDependencies": {
39
+ "@mariozechner/pi-coding-agent": "*"
40
+ },
41
+ "devDependencies": {
42
+ "@mariozechner/pi-coding-agent": "*",
43
+ "@types/node": "^24.5.2",
44
+ "typescript": "^5.9.2"
45
+ }
46
+ }