@falling-ts/pi-start-command 0.0.0-stage → 1.0.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -2
  3. package/index.ts +186 -0
  4. package/package.json +40 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 falling-ts
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 CHANGED
@@ -1,3 +1,59 @@
1
- # Temporary Holding Version
1
+ # pi-start-command
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
+ pi 扩展:在会话真正开始前运行一条配置好的启动命令。参考 [dsh-start-command](https://github.com/falling-ts/dsh-start-command)(DSH 同名插件)移植到 pi 扩展 API。
4
+
5
+ ## 工作原理
6
+
7
+ 挂点为 `before_agent_start`:pi 会 await 全部 handler 之后才组装并派发模型请求,因此在这里 await 命令即满足「先跑命令、再发请求」。命令完成后本轮 prompt 才到达模型。
8
+
9
+ 触发条件(全部满足才运行):
10
+
11
+ 1. 配置了命令(`PI_START_COMMAND` 未设或空白时插件完全静默,零副作用);
12
+ 2. `event.messageType === "user"`——只对用户提交的 prompt 生效。pi v1.2.0 起 `before_agent_start` 也会为 `pi.sendMessage(..., { triggerTurn: true })` 触发(`"custom"` 变体),程序化启动的 run 不算「用户发起了会话」;
13
+ 3. `before_agent_start` 是 run 级事件,一轮内的后续工具循环步不会再次进入,天然幂等。
14
+
15
+ 失败策略:任何失败(非零退出、超时、spawn 失败)只经 `ctx.ui.notify` 告知并放行,绝不抛进事件分发——启动命令必须无法破坏模型请求。
16
+
17
+ ## 配置(环境变量)
18
+
19
+ | 变量 | 说明 | 默认 |
20
+ | --- | --- | --- |
21
+ | `PI_START_COMMAND` | 要运行的命令行;未设或空白则插件完全静默 | (未配置) |
22
+ | `PI_START_COMMAND_TIMEOUT_MS` | 超时毫秒数,到点 kill 进程并放行 | `10000` |
23
+ | `PI_START_COMMAND_QUIET` | 设为 `1` 时成功也不弹通知,只报失败 | (关闭) |
24
+
25
+ 工作目录为当前会话的 `ctx.cwd`。命令行整体交给系统 shell 解析(Windows 为 cmd),`windowsHide` 避免闪终端窗口。
26
+
27
+ ## 安装
28
+
29
+ 方式一(软链本仓库,随仓库更新):
30
+
31
+ ```powershell
32
+ New-Item -ItemType SymbolicLink `
33
+ -Path "$env:USERPROFILE\.pi\agent\extensions\pi-start-command" `
34
+ -Target "D:\AI\pi-s\pi-start-command"
35
+ ```
36
+
37
+ 方式二(npm 包):`pi install npm:pi-start-command`(需代理)。
38
+
39
+ pi 通过 jiti 直接加载 `.ts`,无需构建。
40
+
41
+ ## 使用示例
42
+
43
+ ```powershell
44
+ # 每次会话开始前同步一次远程状态
45
+ $env:PI_START_COMMAND = "git fetch --all --quiet"
46
+
47
+ # 自定义超时 30 秒,成功静默
48
+ $env:PI_START_COMMAND_TIMEOUT_MS = "30000"
49
+ $env:PI_START_COMMAND_QUIET = "1"
50
+ ```
51
+
52
+ 通知示例:
53
+
54
+ - 成功:`[start-command] "git fetch --all --quiet" exit=0 812ms`
55
+ - 失败:`[start-command] "npm test" exit=1 4503ms stdout=… stderr=…`
56
+
57
+ ## License
58
+
59
+ MIT
package/index.ts ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * pi-start-command — 在会话真正开始前运行一条配置好的启动命令。
3
+ *
4
+ * 参考 dsh-start-command(DSH 同名插件)的语义移植到 pi 扩展 API:
5
+ *
6
+ * dsh 挂点 `agent/pre-step`(UserPromptSubmit 的 waterfall,请求派生前最后
7
+ * 一个扩展点)↔ pi 挂点 `before_agent_start`(pi await 全部 handler 后才
8
+ * 组装并派发模型请求)。在 handler 里 await 命令即满足"先跑命令、
9
+ * 再发请求",且不触碰 agent 循环本身。
10
+ *
11
+ * 触发条件(对应 dsh 版的五道门槛中 pi 可表达的部分):
12
+ * 1. 配置了命令(环境变量 `PI_START_COMMAND`,空/未设 = 整个插件静默跳过);
13
+ * 2. `event.messageType === "user"` —— 只对用户提交的 prompt 生效。pi v1.2.0 起
14
+ * `before_agent_start` 也为 `pi.sendMessage(..., { triggerTurn: true })` 触发
15
+ * (`"custom"` 变体),程序化启动的 run 不算"用户发起了会话";
16
+ * 3. 每个 run 只触发一次 —— `before_agent_start` 本身就是 run 级事件,
17
+ * 一轮内的后续工具循环步不会再次进入,天然具备 dsh 版 turn latch 的幂等性。
18
+ *
19
+ * 失败策略与 dsh 版一致:任何失败(非零退出、超时、spawn 失败)只经
20
+ * `ctx.ui.notify` 告知并放行,绝不让异常逃进事件分发 —— 启动命令必须
21
+ * 无法破坏模型请求。
22
+ *
23
+ * 配置(环境变量):
24
+ * - `PI_START_COMMAND` 要运行的命令行;未设或空白则插件完全静默。
25
+ * - `PI_START_COMMAND_TIMEOUT_MS` 超时毫秒数,默认 10000;到点 kill 进程并放行。
26
+ * - `PI_START_COMMAND_QUIET` 设为 `1` 时成功也不弹通知,只报失败。
27
+ *
28
+ * 工作目录:`ctx.cwd`(当前会话的工作目录),与 dsh 版取 session cwd 对齐。
29
+ *
30
+ * 安装:把本目录软链到 `~/.pi/agent/extensions/pi-start-command` 即可
31
+ * (pi 扫描 extensions 目录,按目录内的 `index.ts` 入口经 jiti 加载)。
32
+ */
33
+ import { spawn } from "node:child_process";
34
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
35
+
36
+ /** 默认超时:启动命令不应长期阻塞会话开始。 */
37
+ const DEFAULT_TIMEOUT_MS = 10_000;
38
+
39
+ /** stdout/stderr 尾部带入通知的字符上限(日志边界,不是行为边界)。 */
40
+ const LOG_TAIL_CHARS = 400;
41
+
42
+ /** 读取配置的启动命令,规范化:非字符串或空白视为未配置。 */
43
+ function readStartCommand(): string | undefined {
44
+ const raw = process.env.PI_START_COMMAND;
45
+ if (typeof raw !== "string") return undefined;
46
+ const command = raw.trim();
47
+ return command === "" ? undefined : command;
48
+ }
49
+
50
+ /** 解析超时毫秒数:非法或非正数回退默认值。 */
51
+ function resolveTimeoutMs(): number {
52
+ const raw = Number(process.env.PI_START_COMMAND_TIMEOUT_MS);
53
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS;
54
+ }
55
+
56
+ /** 是否静默成功(只报失败)。 */
57
+ function isQuiet(): boolean {
58
+ return process.env.PI_START_COMMAND_QUIET === "1";
59
+ }
60
+
61
+ /** 文本末尾至多 `LOG_TAIL_CHARS` 字符;空白返回 undefined。 */
62
+ function tail(text: string | undefined): string | undefined {
63
+ if (typeof text !== "string") return undefined;
64
+ const trimmed = text.trim();
65
+ if (trimmed === "") return undefined;
66
+ return trimmed.length > LOG_TAIL_CHARS ? `…${trimmed.slice(-LOG_TAIL_CHARS)}` : trimmed;
67
+ }
68
+
69
+ /** 一次命令执行的结果;`ran` 区分"进程起来了"与"根本没起来"。 */
70
+ interface CommandOutcome {
71
+ ran: boolean;
72
+ reason: string;
73
+ exitCode?: number;
74
+ timedOut?: boolean;
75
+ aborted?: boolean;
76
+ durationMs: number;
77
+ stdoutTail?: string;
78
+ stderrTail?: string;
79
+ error?: string;
80
+ }
81
+
82
+ /**
83
+ * 运行一条启动命令。绝不抛出。
84
+ *
85
+ * 用 `spawn` 而非 `exec`:命令行整体交给系统 shell 解析(Windows 为 cmd),
86
+ * `windowsHide` 避免闪终端窗口;超时到点 kill;输出只取尾部用于通知。
87
+ */
88
+ function runStartCommand(command: string, cwd: string, timeoutMs: number): Promise<CommandOutcome> {
89
+ return new Promise((resolve) => {
90
+ const started = Date.now();
91
+ let child: ReturnType<typeof spawn>;
92
+ try {
93
+ child = spawn(command, {
94
+ shell: true,
95
+ cwd,
96
+ windowsHide: true,
97
+ stdio: ["ignore", "pipe", "pipe"],
98
+ });
99
+ } catch (error) {
100
+ resolve({
101
+ ran: false,
102
+ reason: "spawn-failed",
103
+ durationMs: Date.now() - started,
104
+ error: error instanceof Error ? error.message : String(error),
105
+ });
106
+ return;
107
+ }
108
+
109
+ let stdout = "";
110
+ let stderr = "";
111
+ let timedOut = false;
112
+ let aborted = false;
113
+ child.stdout?.on("data", (chunk: Buffer) => {
114
+ stdout += chunk.toString();
115
+ });
116
+ child.stderr?.on("data", (chunk: Buffer) => {
117
+ stderr += chunk.toString();
118
+ });
119
+
120
+ const timer = setTimeout(() => {
121
+ timedOut = true;
122
+ child.kill();
123
+ }, timeoutMs);
124
+
125
+ child.on("error", (error) => {
126
+ clearTimeout(timer);
127
+ resolve({
128
+ ran: false,
129
+ reason: "spawn-failed",
130
+ durationMs: Date.now() - started,
131
+ error: error.message,
132
+ });
133
+ });
134
+ child.on("close", (code, signal) => {
135
+ clearTimeout(timer);
136
+ resolve({
137
+ ran: true,
138
+ reason: "completed",
139
+ ...(code !== null ? { exitCode: code } : {}),
140
+ ...(timedOut ? { timedOut: true } : {}),
141
+ ...(signal !== null ? { aborted: true } : {}),
142
+ durationMs: Date.now() - started,
143
+ ...(tail(stdout) !== undefined ? { stdoutTail: tail(stdout) } : {}),
144
+ ...(tail(stderr) !== undefined ? { stderrTail: tail(stderr) } : {}),
145
+ });
146
+ });
147
+ });
148
+ }
149
+
150
+ /** 把一次结果渲染成单行通知文本;失败时级别为 warning。 */
151
+ function describeOutcome(command: string, outcome: CommandOutcome): { level: "info" | "warning"; text: string } {
152
+ const parts = [`[start-command] "${command}"`];
153
+ if (outcome.ran !== true) {
154
+ parts.push(`未运行 (${outcome.reason})`);
155
+ if (outcome.error !== undefined) parts.push(`error=${outcome.error}`);
156
+ } else {
157
+ parts.push(`exit=${outcome.exitCode ?? "signal"}`);
158
+ if (outcome.timedOut === true) parts.push("超时");
159
+ }
160
+ parts.push(`${outcome.durationMs}ms`);
161
+ if (outcome.stdoutTail !== undefined) parts.push(`stdout=${outcome.stdoutTail}`);
162
+ if (outcome.stderrTail !== undefined) parts.push(`stderr=${outcome.stderrTail}`);
163
+ const failed =
164
+ outcome.ran !== true ||
165
+ outcome.timedOut === true ||
166
+ outcome.aborted === true ||
167
+ (outcome.exitCode !== undefined && outcome.exitCode !== 0);
168
+ return { level: failed ? "warning" : "info", text: parts.join(" ") };
169
+ }
170
+
171
+ export default function piStartCommand(pi: ExtensionAPI): void {
172
+ pi.on("before_agent_start", async (event, ctx) => {
173
+ // 只对用户提交的 prompt 生效;程序化触发的 run(custom message)跳过。
174
+ if (event.messageType !== "user") return;
175
+
176
+ const command = readStartCommand();
177
+ if (command === undefined) return;
178
+
179
+ const outcome = await runStartCommand(command, ctx.cwd, resolveTimeoutMs());
180
+ const line = describeOutcome(command, outcome);
181
+ if (ctx.hasUI && (line.level === "warning" || !isQuiet())) {
182
+ ctx.ui.notify(line.text, line.level);
183
+ }
184
+ return;
185
+ });
186
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@falling-ts/pi-start-command",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "会话开始前运行一条配置好的启动命令(PI_START_COMMAND)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public",
9
+ "registry": "https://registry.npmjs.org/"
10
+ },
11
+ "author": "falling-ts <zgh.yuanshang@gmail.com>",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/falling-ts/pi-start-command.git"
15
+ },
16
+ "homepage": "https://github.com/falling-ts/pi-start-command#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/falling-ts/pi-start-command/issues"
19
+ },
20
+ "keywords": [
21
+ "pi-package",
22
+ "pi",
23
+ "pi-extension",
24
+ "pi-coding-agent",
25
+ "start-command",
26
+ "hook"
27
+ ],
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "pi": {
32
+ "extensions": ["./index.ts"]
33
+ },
34
+ "files": [
35
+ "index.ts",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "peerDependencies": {
40
+ "@earendil-works/pi-coding-agent": "*"
41
+ }
42
+ }