@arcanemachine/pi-retry 0.2.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.md +21 -0
- package/README.md +57 -0
- package/package.json +55 -0
- package/src/index.ts +219 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
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,57 @@
|
|
|
1
|
+
# pi-retry
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/arcanemachine/pi-retry/main/logo.jpg" alt="pi-retry logo" width="250" />
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
> Re-prompt or nudge the current turn with a shortcut (default `Ctrl+Alt+R`).
|
|
8
|
+
|
|
9
|
+
A Pi extension for:
|
|
10
|
+
|
|
11
|
+
- aborting a slow active response and starting a fresh provider request for the same assistant turn,
|
|
12
|
+
- resuming from an accidentally/intentionally stopped assistant response without typing `continue`, or
|
|
13
|
+
- nudging a new assistant turn from the current context when idle, without typing anything into the editor.
|
|
14
|
+
|
|
15
|
+
## Why?
|
|
16
|
+
|
|
17
|
+
A provider may route through multiple upstream providers, and one of them may be slow. By "retrying" the request, the request is re-sent and will hopefully be routed to a faster provider during the retry.
|
|
18
|
+
|
|
19
|
+
The same shortcut also works while idle: it kicks off a new assistant turn from whatever context is currently there. This is handy for prompting a continuation or follow-up turn without typing an explicit prompt.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
### From local clone
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pi install /path/to/pi-retry
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### From GitHub
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pi install git:github.com/arcanemachine/pi-retry
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Configuration
|
|
36
|
+
|
|
37
|
+
The default shortcut is `Ctrl+Alt+R`. Override it in Pi settings (`.pi/settings.json` in a project or `~/.pi/agent/settings.json` globally):
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"pi-retry": {
|
|
42
|
+
"shortcut": "ctrl+alt+r"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Project settings override global settings.
|
|
48
|
+
|
|
49
|
+
## Behavior
|
|
50
|
+
|
|
51
|
+
1. If Pi is streaming, the shortcut aborts the current assistant response and starts a hidden trigger turn.
|
|
52
|
+
2. If Pi is idle, the shortcut sends a hidden trigger turn when all of the following are true:
|
|
53
|
+
- no pending queued messages
|
|
54
|
+
- editor input is empty
|
|
55
|
+
3. When an assistant response is aborted (either during retry, or when the leaf was already an aborted assistant message), that aborted message is stripped from provider context for the trigger turn. When there is no aborted assistant message in scope, the trigger turn is sent with the current context unchanged.
|
|
56
|
+
4. In all cases the hidden trigger message itself is also stripped from provider context.
|
|
57
|
+
5. Aborted partial output may still remain visible in session history/UI; it is only stripped from provider context for the trigger turn.
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@arcanemachine/pi-retry",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Pi extension to re-prompt or nudge the current assistant turn with a shortcut (default Ctrl+Alt+R)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"watch": "tsc --watch",
|
|
9
|
+
"typecheck": "tsc --noEmit",
|
|
10
|
+
"format": "prettier --write src/index.ts package.json README.md AGENTS.md IDEAS.md tests/*.ts",
|
|
11
|
+
"test": "vitest run"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pi",
|
|
15
|
+
"pi-coding-agent",
|
|
16
|
+
"extension",
|
|
17
|
+
"pi-package",
|
|
18
|
+
"retry",
|
|
19
|
+
"resume",
|
|
20
|
+
"shortcut"
|
|
21
|
+
],
|
|
22
|
+
"pi": {
|
|
23
|
+
"extensions": [
|
|
24
|
+
"./src/index.ts"
|
|
25
|
+
],
|
|
26
|
+
"image": "https://raw.githubusercontent.com/arcanemachine/pi-retry/main/logo.jpg"
|
|
27
|
+
},
|
|
28
|
+
"author": "Nicholas Moen",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
32
|
+
},
|
|
33
|
+
"peerDependenciesMeta": {
|
|
34
|
+
"@earendil-works/pi-coding-agent": {
|
|
35
|
+
"optional": true
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@earendil-works/pi-coding-agent": "^0.74.0",
|
|
40
|
+
"@types/node": "^25.3.0",
|
|
41
|
+
"prettier": "^3.8.3",
|
|
42
|
+
"typescript": "^5.7.2",
|
|
43
|
+
"vitest": "^4.1.6"
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"src",
|
|
47
|
+
"README.md",
|
|
48
|
+
"LICENSE.md"
|
|
49
|
+
],
|
|
50
|
+
"repository": {
|
|
51
|
+
"type": "git",
|
|
52
|
+
"url": "git+https://github.com/arcanemachine/pi-retry.git"
|
|
53
|
+
},
|
|
54
|
+
"homepage": "https://github.com/arcanemachine/pi-retry"
|
|
55
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ContextEvent,
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
ExtensionEvent,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
type Shortcut = Parameters<ExtensionAPI["registerShortcut"]>[0];
|
|
12
|
+
type ContextMessage = ContextEvent["messages"][number];
|
|
13
|
+
type MessageEndEvent = Extract<ExtensionEvent, { type: "message_end" }>;
|
|
14
|
+
|
|
15
|
+
const DEFAULT_RETRY_SHORTCUT = "ctrl+alt+r" as Shortcut;
|
|
16
|
+
const RETRY_TRIGGER_TYPE = "pi-retry:trigger";
|
|
17
|
+
const TRIGGER_RETRY_DELAY_MS = 50;
|
|
18
|
+
const TRIGGER_RETRY_ATTEMPTS = 100;
|
|
19
|
+
|
|
20
|
+
type RetryAction = "retry" | "resume";
|
|
21
|
+
|
|
22
|
+
let retryPending = false;
|
|
23
|
+
let triggerPending = false;
|
|
24
|
+
const suppressedAssistantTimestamps = new Set<number>();
|
|
25
|
+
|
|
26
|
+
function readSettings(path: string): Record<string, unknown> {
|
|
27
|
+
if (!existsSync(path)) return {};
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const raw = readFileSync(path, "utf-8");
|
|
31
|
+
const parsed = JSON.parse(raw);
|
|
32
|
+
return parsed && typeof parsed === "object"
|
|
33
|
+
? (parsed as Record<string, unknown>)
|
|
34
|
+
: {};
|
|
35
|
+
} catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getShortcut(cwd: string): Shortcut {
|
|
41
|
+
const home = process.env.HOME || homedir();
|
|
42
|
+
const globalSettings = readSettings(
|
|
43
|
+
join(home, ".pi", "agent", "settings.json"),
|
|
44
|
+
);
|
|
45
|
+
const projectSettings = readSettings(join(cwd, ".pi", "settings.json"));
|
|
46
|
+
|
|
47
|
+
const globalConfig = globalSettings["pi-retry"];
|
|
48
|
+
const projectConfig = projectSettings["pi-retry"];
|
|
49
|
+
|
|
50
|
+
const globalShortcut =
|
|
51
|
+
globalConfig && typeof globalConfig === "object"
|
|
52
|
+
? (globalConfig as Record<string, unknown>).shortcut
|
|
53
|
+
: undefined;
|
|
54
|
+
const projectShortcut =
|
|
55
|
+
projectConfig && typeof projectConfig === "object"
|
|
56
|
+
? (projectConfig as Record<string, unknown>).shortcut
|
|
57
|
+
: undefined;
|
|
58
|
+
|
|
59
|
+
if (typeof projectShortcut === "string" && projectShortcut.trim()) {
|
|
60
|
+
return projectShortcut.trim() as Shortcut;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (typeof globalShortcut === "string" && globalShortcut.trim()) {
|
|
64
|
+
return globalShortcut.trim() as Shortcut;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return DEFAULT_RETRY_SHORTCUT;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isRetryTrigger(message: ContextMessage): boolean {
|
|
71
|
+
return message.role === "custom" && message.customType === RETRY_TRIGGER_TYPE;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isSuppressedAssistant(message: ContextMessage): boolean {
|
|
75
|
+
return (
|
|
76
|
+
message.role === "assistant" &&
|
|
77
|
+
message.stopReason === "aborted" &&
|
|
78
|
+
suppressedAssistantTimestamps.has(message.timestamp)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function shouldSuppressFromProviderContext(message: ContextMessage): boolean {
|
|
83
|
+
return isRetryTrigger(message) || isSuppressedAssistant(message);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function scheduleRetryTrigger(
|
|
87
|
+
pi: ExtensionAPI,
|
|
88
|
+
ctx: ExtensionContext,
|
|
89
|
+
action: RetryAction,
|
|
90
|
+
suppressAssistantTimestamp?: number,
|
|
91
|
+
attempt = 0,
|
|
92
|
+
): void {
|
|
93
|
+
setTimeout(() => {
|
|
94
|
+
if (!retryPending) return;
|
|
95
|
+
|
|
96
|
+
if (!ctx.isIdle()) {
|
|
97
|
+
if (attempt >= TRIGGER_RETRY_ATTEMPTS) {
|
|
98
|
+
retryPending = false;
|
|
99
|
+
triggerPending = false;
|
|
100
|
+
ctx.ui.notify("Retry failed: agent did not become idle", "warning");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
scheduleRetryTrigger(
|
|
105
|
+
pi,
|
|
106
|
+
ctx,
|
|
107
|
+
action,
|
|
108
|
+
suppressAssistantTimestamp,
|
|
109
|
+
attempt + 1,
|
|
110
|
+
);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
triggerPending = true;
|
|
115
|
+
pi.sendMessage(
|
|
116
|
+
{
|
|
117
|
+
customType: RETRY_TRIGGER_TYPE,
|
|
118
|
+
content: "",
|
|
119
|
+
display: false,
|
|
120
|
+
details: {
|
|
121
|
+
timestamp: Date.now(),
|
|
122
|
+
action,
|
|
123
|
+
suppressAssistantTimestamp,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{ triggerTurn: true },
|
|
127
|
+
);
|
|
128
|
+
}, TRIGGER_RETRY_DELAY_MS);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function handleMessageEnd(event: MessageEndEvent): void {
|
|
132
|
+
if (!retryPending) return;
|
|
133
|
+
|
|
134
|
+
if (
|
|
135
|
+
event.message.role === "assistant" &&
|
|
136
|
+
event.message.stopReason === "aborted"
|
|
137
|
+
) {
|
|
138
|
+
suppressedAssistantTimestamps.add(event.message.timestamp);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function hasEditorText(ctx: ExtensionContext): boolean {
|
|
143
|
+
return ctx.ui.getEditorText().length > 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function getLeafAbortedAssistantTimestamp(
|
|
147
|
+
ctx: ExtensionContext,
|
|
148
|
+
): number | undefined {
|
|
149
|
+
const leaf = ctx.sessionManager.getLeafEntry();
|
|
150
|
+
if (!leaf || leaf.type !== "message") return undefined;
|
|
151
|
+
|
|
152
|
+
const { message } = leaf;
|
|
153
|
+
if (message.role !== "assistant" || message.stopReason !== "aborted") {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return message.timestamp;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function handleContext(
|
|
161
|
+
event: ContextEvent,
|
|
162
|
+
): { messages: ContextMessage[] } | undefined {
|
|
163
|
+
const hasRetryTrigger = event.messages.some(isRetryTrigger);
|
|
164
|
+
const messages = event.messages.filter(
|
|
165
|
+
(message) => !shouldSuppressFromProviderContext(message),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (hasRetryTrigger && triggerPending) {
|
|
169
|
+
retryPending = false;
|
|
170
|
+
triggerPending = false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (messages.length === event.messages.length) return undefined;
|
|
174
|
+
return { messages };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export default function piRetryResponseExtension(pi: ExtensionAPI): void {
|
|
178
|
+
const retryShortcut = getShortcut(process.cwd());
|
|
179
|
+
|
|
180
|
+
pi.on("message_end", handleMessageEnd);
|
|
181
|
+
pi.on("context", handleContext);
|
|
182
|
+
|
|
183
|
+
pi.registerShortcut(retryShortcut, {
|
|
184
|
+
description: "Retry the in-progress assistant response",
|
|
185
|
+
handler: (ctx) => {
|
|
186
|
+
if (retryPending) {
|
|
187
|
+
ctx.ui.notify("Retry already pending", "info");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!ctx.isIdle()) {
|
|
192
|
+
retryPending = true;
|
|
193
|
+
ctx.abort();
|
|
194
|
+
scheduleRetryTrigger(pi, ctx, "retry");
|
|
195
|
+
ctx.ui.notify("Retrying response (from last full reply)", "info");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (ctx.hasPendingMessages()) {
|
|
200
|
+
ctx.ui.notify("Resume skipped: pending messages queued", "info");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (hasEditorText(ctx)) {
|
|
205
|
+
ctx.ui.notify("Resume skipped: editor has unsent text", "info");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const abortedTimestamp = getLeafAbortedAssistantTimestamp(ctx);
|
|
210
|
+
|
|
211
|
+
retryPending = true;
|
|
212
|
+
if (abortedTimestamp !== undefined) {
|
|
213
|
+
suppressedAssistantTimestamps.add(abortedTimestamp);
|
|
214
|
+
}
|
|
215
|
+
scheduleRetryTrigger(pi, ctx, "resume", abortedTimestamp);
|
|
216
|
+
ctx.ui.notify("Resuming stopped response (from last full reply)", "info");
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
}
|