pi-interrupt-steer 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 ezoushen
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,72 @@
1
+ # pi-interrupt-steer
2
+
3
+ Interrupt a running Pi turn and send the editor text as one new user message.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pi install npm:pi-interrupt-steer
9
+ ```
10
+
11
+ ## External contract
12
+
13
+ The default shortcut is `ctrl+alt+enter`. While Pi is streaming, it aborts the
14
+ current operation and waits up to five seconds for Pi to become idle before
15
+ sending the editor text. Pi first restores queued steering messages, then
16
+ queued follow-up messages, and appends the text already in the editor. Each part
17
+ is separated by a blank line. Pi stores steering and follow-up messages in
18
+ separate queues, so their original cross-type typing order is not preserved. The
19
+ extension sends that combined text once, then treats a user `message_start` as
20
+ acceptance only when the message text exactly matches what it sent. For string
21
+ content it compares the string; for content parts it compares their text joined
22
+ together. Other user messages are ignored while it waits up to 60 seconds. It
23
+ clears the editor only if it still contains the submitted text. A prompt rewritten
24
+ by another extension can still be sent, but the original editor text stays and
25
+ the timeout warning appears. While waiting for Pi to become idle or start a
26
+ matching message, repeated shortcut presses show an info notice and do not abort
27
+ or send again. If Pi does not start a matching message within 60 seconds, the text
28
+ stays in the editor and the warning says to check the transcript before sending
29
+ it again; an input handler may have handled the prompt without starting a message.
30
+
31
+ When Pi is idle, the shortcut sends the editor text without aborting. With an
32
+ empty editor and no queued session messages, it leaves the run alone and shows a
33
+ notification. During compaction, the shortcut cannot see Pi's separate
34
+ compaction queue, so an empty-editor press with no session messages visible to
35
+ the shortcut does not interrupt Pi; Pi delivers its compaction queue after
36
+ compaction. For idle sends as well, the extension clears the editor only after a
37
+ matching user `message_start` and only if the editor still contains the submitted
38
+ text.
39
+
40
+ The terminal must send `ctrl+alt+enter` distinctly for this shortcut to fire.
41
+ A terminal without the kitty keyboard protocol may send the legacy `ESC CR`
42
+ sequence instead. Pi interprets that as `alt+enter`: the editor text is queued as
43
+ a follow-up, the current response finishes first, and the run is not interrupted.
44
+
45
+ ## Settings
46
+
47
+ Set `key` in `pi-interrupt-steer.json` in the Pi agent settings directory, or use
48
+ `PI_INTERRUPT_STEER_KEY`:
49
+
50
+ | setting | default | environment variable | purpose |
51
+ |---|---|---|---|
52
+ | `key` | `ctrl+alt+enter` | `PI_INTERRUPT_STEER_KEY` | Shortcut with one or more modifiers and a letter, digit, or named key. |
53
+
54
+ For example:
55
+
56
+ ```json
57
+ {
58
+ "key": "ctrl+shift+x"
59
+ }
60
+ ```
61
+
62
+ Valid modifiers are `ctrl`, `alt`, `shift`, and `super`. Valid named keys are
63
+ `enter`, `escape`, `tab`, `space`, `backspace`, `delete`, `up`, `down`, `left`,
64
+ `right`, `home`, and `end`.
65
+
66
+ ## If the contract is unmet
67
+
68
+ An invalid key uses `ctrl+alt+enter` and shows one warning. If Pi does not emit
69
+ a matching user `message_start` within 60 seconds after the send, the text remains
70
+ in the editor and the extension warns you to check the transcript before sending
71
+ it again. If the editor changes while Pi is processing the message, the extension
72
+ leaves the current text there when it differs from the submitted text.
@@ -0,0 +1,184 @@
1
+ // shared/announce.ts
2
+ var stderrAnnounced = /* @__PURE__ */ new Set();
3
+ function announce(ctx, message, level, reason = message) {
4
+ if (ctx?.hasUI === false) {
5
+ if (stderrAnnounced.has(reason)) return;
6
+ stderrAnnounced.add(reason);
7
+ try {
8
+ process.stderr.write(`${message}
9
+ `);
10
+ } catch {
11
+ }
12
+ return;
13
+ }
14
+ try {
15
+ ctx?.ui?.notify?.(message, level);
16
+ } catch {
17
+ }
18
+ }
19
+
20
+ // shared/settings.ts
21
+ import { existsSync, readFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
24
+ var announcedConfigErrors = /* @__PURE__ */ new Set();
25
+ function readConfig(path, ctx) {
26
+ if (!existsSync(path)) return {};
27
+ try {
28
+ return JSON.parse(readFileSync(path, "utf8"));
29
+ } catch (error) {
30
+ if (!announcedConfigErrors.has(path)) {
31
+ announcedConfigErrors.add(path);
32
+ const message = error instanceof Error ? error.message : String(error);
33
+ announce(ctx, `settings: could not parse ${path} (${message}); using defaults.`, "warning", `settings-parse:${path}`);
34
+ }
35
+ return {};
36
+ }
37
+ }
38
+ function resolveSettings(name, definitions, context, runtime = {}) {
39
+ const environment = runtime.environment ?? process.env;
40
+ const globalPath = join(runtime.agentDir ?? getAgentDir(), `${name}.json`);
41
+ const projectPath = join(context.cwd, CONFIG_DIR_NAME, `${name}.json`);
42
+ const globalConfig = readConfig(globalPath, context);
43
+ const projectConfig = context.isProjectTrusted() ? readConfig(projectPath, context) : {};
44
+ const resolved = {};
45
+ for (const key of Object.keys(definitions)) {
46
+ const definition = definitions[key];
47
+ let value = definition.default;
48
+ let provenance = { source: "default" };
49
+ if (definition.discover) {
50
+ try {
51
+ const discovered = definition.discover();
52
+ if (discovered !== void 0) {
53
+ value = discovered.value;
54
+ provenance = { source: "discovered", name: definition.discoverName ?? "discovery" };
55
+ }
56
+ } catch {
57
+ }
58
+ }
59
+ if (Object.hasOwn(globalConfig, key)) {
60
+ value = globalConfig[key];
61
+ provenance = { source: "global", path: globalPath };
62
+ }
63
+ if (Object.hasOwn(projectConfig, key)) {
64
+ value = projectConfig[key];
65
+ provenance = { source: "project", path: projectPath };
66
+ }
67
+ const environmentValue = environment[definition.env];
68
+ if (environmentValue !== void 0) {
69
+ value = definition.parseEnv ? definition.parseEnv(environmentValue) : environmentValue;
70
+ provenance = { source: "environment", name: definition.env };
71
+ }
72
+ resolved[key] = { value, provenance };
73
+ }
74
+ return resolved;
75
+ }
76
+
77
+ // extensions/interrupt-steer/interrupt-steer.ts
78
+ var DEFAULT_KEY = "ctrl+alt+enter";
79
+ var KEY_SETTING = { key: { default: DEFAULT_KEY, env: "PI_INTERRUPT_STEER_KEY" } };
80
+ var IDLE_WAIT_TIMEOUT_MS = 5e3;
81
+ var MESSAGE_ACCEPT_TIMEOUT_MS = 6e4;
82
+ var IDLE_POLL_INTERVAL_MS = 25;
83
+ async function waitUntilIdle(ctx) {
84
+ const deadline = Date.now() + IDLE_WAIT_TIMEOUT_MS;
85
+ while (!ctx.isIdle()) {
86
+ const remaining = deadline - Date.now();
87
+ if (remaining <= 0) return false;
88
+ await new Promise((resolve) => setTimeout(resolve, Math.min(IDLE_POLL_INTERVAL_MS, remaining)));
89
+ }
90
+ return true;
91
+ }
92
+ function isShortcutKey(value) {
93
+ if (typeof value !== "string") return false;
94
+ const parts = value.split("+");
95
+ const base = parts.pop();
96
+ return parts.length > 0 && new Set(parts).size === parts.length && parts.every((part) => ["ctrl", "alt", "shift", "super"].includes(part)) && base !== void 0 && (/^[a-z0-9]$/.test(base) || ["enter", "escape", "tab", "space", "backspace", "delete", "up", "down", "left", "right", "home", "end"].includes(base));
97
+ }
98
+ function waitForAcceptedUserMessage(pi, text) {
99
+ return new Promise((resolve) => {
100
+ let timeout;
101
+ let unsubscribe;
102
+ let settled = false;
103
+ const finish = (accepted) => {
104
+ if (settled) return;
105
+ settled = true;
106
+ if (timeout !== void 0) clearTimeout(timeout);
107
+ unsubscribe?.();
108
+ resolve(accepted);
109
+ };
110
+ unsubscribe = pi.on("message_start", (event) => {
111
+ const content = event.message.content;
112
+ const messageText = typeof content === "string" ? content : content.filter((part) => part.type === "text").map((part) => part.text).join("");
113
+ if (event.message.role === "user" && messageText === text) finish(true);
114
+ });
115
+ timeout = setTimeout(() => finish(false), MESSAGE_ACCEPT_TIMEOUT_MS);
116
+ try {
117
+ pi.sendUserMessage(text);
118
+ } catch {
119
+ finish(false);
120
+ }
121
+ });
122
+ }
123
+ async function sendEditorText(pi, ctx, text) {
124
+ if (!text) {
125
+ announce(ctx, "pi-interrupt-steer: nothing to send", "info");
126
+ return;
127
+ }
128
+ if (!await waitForAcceptedUserMessage(pi, text)) {
129
+ announce(ctx, "pi-interrupt-steer: Pi has not started the message yet; text was kept. Check the transcript before sending it again.", "warning");
130
+ return;
131
+ }
132
+ if (ctx.ui.getEditorText() === text) ctx.ui.setEditorText("");
133
+ }
134
+ function registerInterruptSteer(pi, settingsRuntime = {}) {
135
+ const resolved = resolveSettings("pi-interrupt-steer", KEY_SETTING, {
136
+ cwd: process.cwd(),
137
+ hasUI: true,
138
+ isProjectTrusted: () => false
139
+ }, settingsRuntime);
140
+ const configuredKey = resolved.key.value;
141
+ const validKey = isShortcutKey(configuredKey);
142
+ const key = validKey ? configuredKey : DEFAULT_KEY;
143
+ let warnedAboutKey = false;
144
+ let inFlight = false;
145
+ pi.on("session_start", (_event, ctx) => {
146
+ if (!validKey && !warnedAboutKey) {
147
+ announce(ctx, "pi-interrupt-steer: invalid key setting; using ctrl+alt+enter", "warning", "pi-interrupt-steer:invalid-key");
148
+ warnedAboutKey = true;
149
+ }
150
+ });
151
+ pi.registerShortcut(key, {
152
+ description: "Interrupt the current run and send the editor text",
153
+ handler: async (ctx) => {
154
+ if (inFlight) {
155
+ announce(ctx, "pi-interrupt-steer: already waiting for Pi; this press did not send again", "info");
156
+ return;
157
+ }
158
+ inFlight = true;
159
+ try {
160
+ const idle = ctx.isIdle();
161
+ const text = ctx.ui.getEditorText();
162
+ if (idle) {
163
+ await sendEditorText(pi, ctx, text);
164
+ return;
165
+ }
166
+ if (!text && !ctx.hasPendingMessages()) {
167
+ announce(ctx, "pi-interrupt-steer: nothing to send", "info");
168
+ return;
169
+ }
170
+ ctx.abort();
171
+ if (!await waitUntilIdle(ctx)) {
172
+ announce(ctx, "pi-interrupt-steer: agent did not become idle within 5 seconds; text left in the editor", "warning");
173
+ return;
174
+ }
175
+ await sendEditorText(pi, ctx, ctx.ui.getEditorText());
176
+ } finally {
177
+ inFlight = false;
178
+ }
179
+ }
180
+ });
181
+ }
182
+ export {
183
+ registerInterruptSteer as default
184
+ };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "pi-interrupt-steer",
3
+ "version": "0.1.0",
4
+ "description": "Interrupt a Pi run and send the editor text as the next prompt.",
5
+ "type": "module",
6
+ "main": "./interrupt-steer.js",
7
+ "exports": "./interrupt-steer.js",
8
+ "files": [
9
+ "interrupt-steer.js",
10
+ "README.md",
11
+ "LICENSE"
12
+ ],
13
+ "keywords": [
14
+ "pi-package"
15
+ ],
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/ezoushen/pi-extensions.git",
20
+ "directory": "extensions/interrupt-steer"
21
+ },
22
+ "homepage": "https://github.com/ezoushen/pi-extensions/tree/main/extensions/interrupt-steer#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/ezoushen/pi-extensions/issues"
25
+ },
26
+ "peerDependencies": {
27
+ "@earendil-works/pi-coding-agent": "*"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "pi": {
33
+ "extensions": [
34
+ "./interrupt-steer.js"
35
+ ]
36
+ }
37
+ }