@vimsucks/pi-goon 0.0.0-stage → 0.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/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,73 @@
1
- # Temporary Holding Version
1
+ # @vimsucks/pi-goon
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 通过 `/goon` 继续当前 Pi 会话,不向模型发送新的继续提示词。
4
+
5
+ ## 安装
6
+
7
+ 要求 Pi 1.0.0 或更新版本。
8
+
9
+ 当前可以从 GitHub 安装:
10
+
11
+ ```sh
12
+ pi install git:github.com/vimsucks/pi-goon
13
+ ```
14
+
15
+ npm 包名为 `@vimsucks/pi-goon`。发布到 npm 后,可以使用以下命令安装:
16
+
17
+ ```sh
18
+ pi install npm:@vimsucks/pi-goon
19
+ ```
20
+
21
+ 安装后,在当前会话执行 `/reload`,或重新启动 Pi。
22
+
23
+ ## 使用
24
+
25
+ - `/goon`:启动隐形继续。若当前正在生成,Pi 将继续请求加入原生 follow-up 队列。
26
+ - `/goon status`:显示会话是否空闲,以及最后一条 assistant 消息的文本。
27
+ - `/goon help`:显示命令帮助。
28
+
29
+ 网络请求失败或取消生成后,只要会话仍然存在,就可以执行 `/goon`。如果 Pi 已经退出,先恢复原会话,再执行 `/goon`。
30
+
31
+ ## 快捷键配置
32
+
33
+ 默认快捷键为 `Alt+G`,行为与 `/goon` 相同。快捷键不会提交或清空输入框中的文字。运行 `/hotkeys` 可以检查 Pi 当前启用的快捷键。
34
+
35
+ 要修改快捷键,在 `~/.pi/agent/settings.json` 中添加 `goon` 配置,保留已有的其他字段:
36
+
37
+ ```json
38
+ {
39
+ "goon": {
40
+ "shortcut": "ctrl+alt+g"
41
+ }
42
+ }
43
+ ```
44
+
45
+ `goon.shortcut` 使用 Pi 按键标识符,例如 `alt+g`、`ctrl+alt+g` 或 `f6`。设为 `null` 可以禁用快捷键,`/goon` 命令仍然可用。缺少配置文件或 `goon.shortcut` 字段时,扩展使用 `alt+g`。
46
+
47
+ 扩展不再读取旧的 `goon.json`。如果已有该文件,将其中的 `shortcut` 值迁入 `settings.json` 的 `goon.shortcut` 字段。
48
+
49
+ 如果设置了 `PI_CODING_AGENT_DIR`,配置文件位于该目录下的 `settings.json`。配置仅作用于当前用户,不读取项目配置。修改配置后,执行 `/reload`。如果配置无效,Pi 会报告加载错误;修正配置后再次执行 `/reload`。
50
+
51
+ 快捷键由 Pi 原生机制处理。与其他快捷键冲突时,按 Pi 的冲突规则处理。macOS 终端需要将 Option 配置为 Alt/Meta,才能发送 `Alt+G`。
52
+
53
+ ## 工作方式与限制
54
+
55
+ 扩展通过 `pi.sendMessage()` 添加一条隐藏标记,并启动原生会话轮次。`context` 事件处理器在模型请求发出前移除该标记。模型不会收到新的 `go on` 提示词,界面也不会显示该标记,但会话文件仍保留隐藏记录。
56
+
57
+ 存在标记时,处理器还会移除末尾失败或取消的 assistant 消息。它保留带工具调用的消息,防止破坏工具调用与结果的配对。处理器不改写会话历史。
58
+
59
+ 扩展不会自动恢复,也不保证从中断的某个字精确接续。它保留 Pi 原生队列、重试和压缩行为。
60
+
61
+ ## 验证
62
+
63
+ 使用 Node.js 24 或更新版本运行无网络检查:
64
+
65
+ ```sh
66
+ npm test
67
+ ```
68
+
69
+ ## 来源与许可
70
+
71
+ 本项目基于 [monotykamary/pi-invisible-continue](https://github.com/monotykamary/pi-invisible-continue) 的 npm 版本 `0.3.16`。原作者为 Tom X Nguyen,原包声明 MIT 许可。本项目保留其隐形继续与消息过滤逻辑,将命令改为 `/goon`,并使用独立的 `pi-goon:resume` 标记。
72
+
73
+ 许可见 [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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.3.0",
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
+ }