@vimsucks/pi-goon 0.0.0-stage → 0.3.1
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 +22 -0
- package/README.md +74 -2
- package/goon.ts +83 -0
- package/package.json +35 -3
- package/src/index.ts +134 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tom X Nguyen
|
|
4
|
+
Copyright (c) 2026 vimsucks
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,75 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @vimsucks/pi-goon
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Resume the current Pi session with `/goon` without sending a new continuation prompt to the model.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Requires Pi 1.0.0 or later.
|
|
8
|
+
|
|
9
|
+
Install from npm:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pi install npm:@vimsucks/pi-goon
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or install from GitHub:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pi install git:github.com/vimsucks/pi-goon
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
After installation, run `/reload` in the current session or restart Pi.
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
- `/goon`: start an invisible continuation. If the agent is already running, Pi queues the continuation through its native follow-up queue.
|
|
26
|
+
- `/goon status`: show whether the session is idle and the text of the last assistant message.
|
|
27
|
+
- `/goon help`: show command help.
|
|
28
|
+
|
|
29
|
+
After a network request fails or you cancel generation, run `/goon` to resume the task in the same session. If Pi has exited, reopen the original session before running `/goon`.
|
|
30
|
+
|
|
31
|
+
## Keyboard shortcut
|
|
32
|
+
|
|
33
|
+
The default shortcut is `Alt+G`. It performs the same action as `/goon` without submitting or clearing text in the editor. Run `/hotkeys` to inspect the active shortcuts.
|
|
34
|
+
|
|
35
|
+
To change the shortcut, add a `goon` object to `~/.pi/agent/settings.json`. Keep the other settings in that file:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"goon": {
|
|
40
|
+
"shortcut": "ctrl+alt+g"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`goon.shortcut` accepts Pi key identifiers such as `alt+g`, `ctrl+alt+g`, or `f6`. Set it to `null` to disable the shortcut; `/goon` remains available. If the settings file or the `goon.shortcut` field is absent, the extension uses `alt+g`.
|
|
46
|
+
|
|
47
|
+
The extension no longer reads `goon.json`. If you have that file, move its `shortcut` value to `goon.shortcut` in `settings.json`.
|
|
48
|
+
|
|
49
|
+
If `PI_CODING_AGENT_DIR` is set, the extension reads `settings.json` from that directory. Only user-level settings apply; the extension does not read project settings.
|
|
50
|
+
|
|
51
|
+
After changing the configuration, run `/reload`. If the configuration is invalid, Pi reports a loading error. Correct the configuration and run `/reload` again.
|
|
52
|
+
|
|
53
|
+
Pi handles the shortcut through its native shortcut mechanism and applies its normal conflict rules. On macOS, configure the terminal to treat Option as Alt/Meta so it can send `Alt+G`. In Otty, open **Settings → Controls → Option as Alt** and select **Left** or **Both**. Otherwise, Option+G may insert `©` instead of triggering the shortcut.
|
|
54
|
+
|
|
55
|
+
## How it works and limitations
|
|
56
|
+
|
|
57
|
+
The extension uses `pi.sendMessage()` to add a hidden marker and start a native session turn. A `context` handler removes the marker before Pi sends a request to the model. The model receives no new `go on` prompt, and the marker is not displayed in the UI. The session journal still contains a hidden bookkeeping entry.
|
|
58
|
+
|
|
59
|
+
When a marker is present, the handler also removes trailing failed or aborted assistant messages from the request. It retains messages with tool calls to preserve tool-call/result pairs. It does not rewrite session history.
|
|
60
|
+
|
|
61
|
+
The extension does not resume automatically or guarantee continuation from the exact character where generation stopped. It preserves Pi's native queues, retries, and compaction behavior.
|
|
62
|
+
|
|
63
|
+
## Development check
|
|
64
|
+
|
|
65
|
+
Use Node.js 24 or later to run the offline check:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
npm test
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Attribution and license
|
|
72
|
+
|
|
73
|
+
This project is based on npm version `0.3.16` of [monotykamary/pi-invisible-continue](https://github.com/monotykamary/pi-invisible-continue), authored by Tom X Nguyen and declared under the MIT license. It retains the invisible continuation and message-filtering logic, renames the command to `/goon`, and uses its own `pi-goon:resume` marker.
|
|
74
|
+
|
|
75
|
+
See [LICENSE](LICENSE).
|
package/goon.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
CONTINUE_COMMAND_DESCRIPTION,
|
|
4
|
+
INVISIBLE_CONTINUE_CUSTOM_TYPE,
|
|
5
|
+
getLastAssistantMessageText,
|
|
6
|
+
isInvisibleContinueMarker,
|
|
7
|
+
readShortcut,
|
|
8
|
+
stripTrailingIncompleteAssistants,
|
|
9
|
+
} from "./src/index.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resume through AgentSession instead of calling Agent.prompt([]) directly.
|
|
13
|
+
* The hidden custom message starts or queues a canonical session turn, while
|
|
14
|
+
* the context hook removes it before provider serialization.
|
|
15
|
+
*/
|
|
16
|
+
export default function invisibleContinueExtension(pi: ExtensionAPI) {
|
|
17
|
+
const shortcut = readShortcut();
|
|
18
|
+
if (shortcut !== null) {
|
|
19
|
+
pi.registerShortcut(shortcut, {
|
|
20
|
+
description: CONTINUE_COMMAND_DESCRIPTION,
|
|
21
|
+
handler: (ctx) => runContinueCommand(pi, ctx, ""),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
pi.on("context", (event) => {
|
|
25
|
+
const hasMarker = event.messages.some((message) => isInvisibleContinueMarker(message));
|
|
26
|
+
const messages = event.messages.filter((message) => !isInvisibleContinueMarker(message));
|
|
27
|
+
const next = hasMarker ? stripTrailingIncompleteAssistants(messages) : messages;
|
|
28
|
+
if (next.length !== event.messages.length) return { messages: next };
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
pi.registerCommand("goon", {
|
|
32
|
+
description: CONTINUE_COMMAND_DESCRIPTION,
|
|
33
|
+
handler: async (args, ctx) => {
|
|
34
|
+
runContinueCommand(pi, ctx, args);
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function runContinueCommand(
|
|
40
|
+
pi: ExtensionAPI,
|
|
41
|
+
ctx: ExtensionContext,
|
|
42
|
+
args: string,
|
|
43
|
+
): void {
|
|
44
|
+
const subcommand = args.trim().toLowerCase();
|
|
45
|
+
|
|
46
|
+
if (subcommand === "status") {
|
|
47
|
+
const last = getLastAssistantMessageText(ctx.sessionManager.getEntries());
|
|
48
|
+
ctx.ui.notify(
|
|
49
|
+
[
|
|
50
|
+
"pi-goon status:",
|
|
51
|
+
` Agent idle: ${ctx.isIdle() ? "yes" : "no"}`,
|
|
52
|
+
` Last assistant: ${last ?? "(none)"}`.slice(0, 120),
|
|
53
|
+
].join("\n"),
|
|
54
|
+
"info",
|
|
55
|
+
);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (subcommand === "help") {
|
|
60
|
+
ctx.ui.notify(
|
|
61
|
+
[
|
|
62
|
+
"pi-goon /goon Resume loop invisibly",
|
|
63
|
+
" /goon status Show diagnostics",
|
|
64
|
+
" /goon help This message",
|
|
65
|
+
].join("\n"),
|
|
66
|
+
"info",
|
|
67
|
+
);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
pi.sendMessage(
|
|
72
|
+
{
|
|
73
|
+
customType: INVISIBLE_CONTINUE_CUSTOM_TYPE,
|
|
74
|
+
content: [],
|
|
75
|
+
display: false,
|
|
76
|
+
details: undefined,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
triggerTurn: true,
|
|
80
|
+
deliverAs: "followUp",
|
|
81
|
+
},
|
|
82
|
+
);
|
|
83
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vimsucks/pi-goon",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Resume a Pi session invisibly with /goon, without a new prompt for the model",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "vimsucks",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/vimsucks/pi-goon.git"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi-package",
|
|
17
|
+
"pi-extension",
|
|
18
|
+
"continue",
|
|
19
|
+
"resume"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
"goon.ts",
|
|
23
|
+
"src/",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@earendil-works/pi-coding-agent": ">=1.0.0"
|
|
29
|
+
},
|
|
30
|
+
"pi": {
|
|
31
|
+
"extensions": [
|
|
32
|
+
"./goon.ts"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "node --test test.mjs"
|
|
37
|
+
}
|
|
6
38
|
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/** Shared constants and utilities for pi-goon. */
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
export function readShortcut(): Parameters<ExtensionAPI["registerShortcut"]>[0] | null {
|
|
9
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR?.replace(/^~(?=$|[\\/])/, homedir())
|
|
10
|
+
|| join(homedir(), ".pi", "agent");
|
|
11
|
+
const path = join(agentDir, "settings.json");
|
|
12
|
+
let config: unknown;
|
|
13
|
+
try {
|
|
14
|
+
config = JSON.parse(readFileSync(path, "utf8"));
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "alt+g";
|
|
17
|
+
throw new Error(`Cannot read ${path}`, { cause: error });
|
|
18
|
+
}
|
|
19
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
20
|
+
throw new Error(`${path} must contain a JSON object`);
|
|
21
|
+
}
|
|
22
|
+
const goon = (config as { goon?: unknown }).goon;
|
|
23
|
+
if (goon === undefined) return "alt+g";
|
|
24
|
+
if (!goon || typeof goon !== "object" || Array.isArray(goon)) {
|
|
25
|
+
throw new Error(`${path}: goon must be a JSON object`);
|
|
26
|
+
}
|
|
27
|
+
const shortcut = (goon as { shortcut?: unknown }).shortcut;
|
|
28
|
+
if (shortcut === undefined) return "alt+g";
|
|
29
|
+
if (shortcut === null) return null;
|
|
30
|
+
if (typeof shortcut === "string") {
|
|
31
|
+
const match = /^((?:(?:ctrl|shift|alt|super)\+)*)(.+)$/.exec(shortcut);
|
|
32
|
+
if (match) {
|
|
33
|
+
const modifiers = match[1].split("+").filter(Boolean);
|
|
34
|
+
const key = match[2];
|
|
35
|
+
const validKey = /^[a-z0-9]$/.test(key)
|
|
36
|
+
|| (key.length === 1 && "`-=[]\\;',./!@#$%^&*()_+|~{}:<>?".includes(key))
|
|
37
|
+
|| /^(escape|esc|enter|return|tab|space|backspace|delete|insert|clear|home|end|pageUp|pageDown|up|down|left|right|f[1-9]|f1[0-2])$/.test(key);
|
|
38
|
+
if (validKey && new Set(modifiers).size === modifiers.length) {
|
|
39
|
+
return shortcut as Parameters<ExtensionAPI["registerShortcut"]>[0];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`${path}: goon.shortcut must be a Pi key identifier (e.g. alt+g) or null`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const CONTINUE_COMMAND_DESCRIPTION =
|
|
47
|
+
"Resume the agentic loop without sending a prompt the LLM can read";
|
|
48
|
+
|
|
49
|
+
export const INVISIBLE_CONTINUE_CUSTOM_TYPE = "pi-goon:resume";
|
|
50
|
+
|
|
51
|
+
export function isInvisibleContinueMarker(message: unknown): boolean {
|
|
52
|
+
if (!message || typeof message !== "object") return false;
|
|
53
|
+
const candidate = message as { role?: unknown; customType?: unknown };
|
|
54
|
+
return (
|
|
55
|
+
candidate.role === "custom" &&
|
|
56
|
+
candidate.customType === INVISIBLE_CONTINUE_CUSTOM_TYPE
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const INCOMPLETE_ASSISTANT_STOP_REASONS = new Set(["error", "aborted"]);
|
|
61
|
+
|
|
62
|
+
export function isIncompleteAssistantMessage(message: unknown): boolean {
|
|
63
|
+
if (!message || typeof message !== "object") return false;
|
|
64
|
+
const candidate = message as { role?: unknown; stopReason?: unknown };
|
|
65
|
+
return (
|
|
66
|
+
candidate.role === "assistant" &&
|
|
67
|
+
typeof candidate.stopReason === "string" &&
|
|
68
|
+
INCOMPLETE_ASSISTANT_STOP_REASONS.has(candidate.stopReason)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function hasToolCallBlocks(message: unknown): boolean {
|
|
73
|
+
if (!message || typeof message !== "object") return false;
|
|
74
|
+
const content = (message as { content?: unknown }).content;
|
|
75
|
+
return (
|
|
76
|
+
Array.isArray(content) &&
|
|
77
|
+
content.some(
|
|
78
|
+
(block) =>
|
|
79
|
+
!!block &&
|
|
80
|
+
typeof block === "object" &&
|
|
81
|
+
(block as { type?: unknown }).type === "toolCall",
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Pop trailing incomplete assistant attempts (retry-exhausted errors, aborts)
|
|
88
|
+
* so a continuation does not re-serve the failed run to the LLM.
|
|
89
|
+
*
|
|
90
|
+
* Guards: stops at the first message that is not an incomplete assistant,
|
|
91
|
+
* never pops an assistant carrying toolCall blocks (their tool results must
|
|
92
|
+
* stay paired), and never empties the context entirely.
|
|
93
|
+
*/
|
|
94
|
+
export function stripTrailingIncompleteAssistants<T>(
|
|
95
|
+
messages: ReadonlyArray<T>,
|
|
96
|
+
): T[] {
|
|
97
|
+
const stripped = [...messages];
|
|
98
|
+
while (
|
|
99
|
+
stripped.length > 1 &&
|
|
100
|
+
isIncompleteAssistantMessage(stripped[stripped.length - 1]) &&
|
|
101
|
+
!hasToolCallBlocks(stripped[stripped.length - 1])
|
|
102
|
+
) {
|
|
103
|
+
stripped.pop();
|
|
104
|
+
}
|
|
105
|
+
return stripped;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function getLastAssistantMessageText(
|
|
109
|
+
entries: ReadonlyArray<{ type: string; message?: { role?: string; content?: unknown } }>,
|
|
110
|
+
): string | undefined {
|
|
111
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
112
|
+
const entry = entries[i];
|
|
113
|
+
if (
|
|
114
|
+
entry.type === "message" &&
|
|
115
|
+
entry.message?.role === "assistant" &&
|
|
116
|
+
entry.message.content
|
|
117
|
+
) {
|
|
118
|
+
const content = entry.message.content;
|
|
119
|
+
if (typeof content === "string") return content;
|
|
120
|
+
if (Array.isArray(content)) {
|
|
121
|
+
const textBlocks = content.filter(
|
|
122
|
+
(block: unknown): block is { type: "text"; text: string } => {
|
|
123
|
+
if (!block || typeof block !== "object") return false;
|
|
124
|
+
const candidate = block as { type?: unknown; text?: unknown };
|
|
125
|
+
return candidate.type === "text" && typeof candidate.text === "string";
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
if (textBlocks.length === 0) return undefined;
|
|
129
|
+
return textBlocks.map((block) => block.text).join("\n");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|