@haemmid/pi-processes 0.9.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.
@@ -0,0 +1,56 @@
1
+ export type ProcessStatus =
2
+ | "running"
3
+ | "terminating"
4
+ | "terminate_timeout"
5
+ | "exited"
6
+ | "killed";
7
+
8
+ export const LIVE_STATUSES: ReadonlySet<ProcessStatus> = new Set([
9
+ "running",
10
+ "terminating",
11
+ "terminate_timeout",
12
+ ]);
13
+
14
+ export interface ProcessInfo {
15
+ id: string;
16
+ name: string;
17
+ pid: number; // On Unix, this is also the PGID (process group leader)
18
+ command: string;
19
+ cwd: string;
20
+ startTime: number;
21
+ endTime: number | null;
22
+ status: ProcessStatus;
23
+ exitCode: number | null;
24
+ success: boolean | null; // null if running, true if exit code 0, false otherwise
25
+ stdoutFile: string;
26
+ stderrFile: string;
27
+ }
28
+
29
+ export type ManagerEvent =
30
+ | { type: "process_started"; info: ProcessInfo }
31
+ | { type: "process_ended"; info: ProcessInfo; triggerAgentTurn: boolean }
32
+ | { type: "processes_changed" };
33
+
34
+ export type KillResult =
35
+ | { ok: true; info: ProcessInfo }
36
+ | { ok: false; info: ProcessInfo; reason: "not_found" | "timeout" | "error" };
37
+
38
+ export type ResolveProcessResult =
39
+ | { ok: true; info: ProcessInfo }
40
+ | { ok: false; reason: "not_found" | "ambiguous"; matches?: ProcessInfo[] };
41
+
42
+ export interface ProcessesDetails {
43
+ action: string;
44
+ success: boolean;
45
+ message: string;
46
+ process?: ProcessInfo;
47
+ processes?: ProcessInfo[];
48
+ output?: { stdout: string[]; stderr: string[]; status: string };
49
+ logFiles?: { stdoutFile: string; stderrFile: string; combinedFile: string };
50
+ cleared?: number;
51
+ }
52
+
53
+ export interface ExecuteResult {
54
+ content: Array<{ type: "text"; text: string }>;
55
+ details: ProcessesDetails;
56
+ }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Blocks background bash commands (e.g. `cmd &`, `nohup cmd`) and obvious
3
+ * long-running foreground commands (e.g. `pnpm dev`, `tail -f`) and guides
4
+ * the model to use the process tool instead.
5
+ *
6
+ * Controlled via config: `interception.blockBackgroundCommands`.
7
+ */
8
+
9
+ import { basename } from "node:path";
10
+ import { type Program, parse } from "@aliou/sh";
11
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { walkCommands, wordToString } from "../utils/shell-utils";
13
+
14
+ const BACKGROUND_CMD_NAMES = new Set(["nohup", "disown", "setsid"]);
15
+ const BACKGROUND_PATTERN = /&\s*$/;
16
+ const PACKAGE_MANAGERS = new Set(["npm", "pnpm", "yarn", "bun"]);
17
+ const LONG_RUNNING_SCRIPT_NAMES = new Set([
18
+ "dev",
19
+ "start",
20
+ "serve",
21
+ "preview",
22
+ "watch",
23
+ ]);
24
+ const DIRECT_LONG_RUNNING_COMMANDS = new Set([
25
+ "vite",
26
+ "nodemon",
27
+ "webpack-dev-server",
28
+ "uvicorn",
29
+ "foreman",
30
+ "honcho",
31
+ ]);
32
+ const SHELL_LAUNCHERS = new Set(["bash", "sh", "zsh", "fish"]);
33
+ const FOLLOW_FLAGS = new Set(["-f", "--follow"]);
34
+ const WATCH_FLAGS = new Set([
35
+ "--watch",
36
+ "--watchall",
37
+ "--watch-all",
38
+ "--watchfiles",
39
+ "--reload",
40
+ ]);
41
+ const DETACH_FLAGS = new Set(["-d", "--detach"]);
42
+ const SUSPICIOUS_SCRIPT_NAME =
43
+ /(^|[-_.])(dev|serve|server|start|watch|tail|logs?|port[-_]?forward|preview)([-_.]|$)/i;
44
+
45
+ interface ManagedCommandDecision {
46
+ kind: "background" | "long_running";
47
+ suggestedName: string;
48
+ }
49
+
50
+ export function analyzeManagedCommand(
51
+ command: string,
52
+ ): ManagedCommandDecision | undefined {
53
+ try {
54
+ const { ast } = parse(command);
55
+
56
+ for (const stmt of ast.body) {
57
+ if (stmt.background) {
58
+ return {
59
+ kind: "background",
60
+ suggestedName:
61
+ findFirstCommandName(command, ast) ?? "background-process",
62
+ };
63
+ }
64
+ }
65
+
66
+ let decision: ManagedCommandDecision | undefined;
67
+ walkCommands(ast, (cmd) => {
68
+ const words = cmd.words?.map(wordToString).filter(Boolean) ?? [];
69
+ if (words.length === 0) return false;
70
+
71
+ decision = classifySimpleCommand(words);
72
+ return decision !== undefined;
73
+ });
74
+
75
+ return decision;
76
+ } catch {
77
+ return analyzeManagedCommandFallback(command);
78
+ }
79
+ }
80
+
81
+ export function setupBackgroundBlocker(pi: ExtensionAPI): void {
82
+ pi.on("tool_call", async (event, ctx) => {
83
+ if (event.toolName !== "bash") return;
84
+
85
+ const command = String(event.input.command ?? "");
86
+ const decision = analyzeManagedCommand(command);
87
+
88
+ if (!decision) return;
89
+
90
+ const isBackground = decision.kind === "background";
91
+ ctx.ui?.notify(
92
+ isBackground
93
+ ? "Blocked background command. Use process instead."
94
+ : "Blocked long-running command. Use process instead.",
95
+ "warning",
96
+ );
97
+
98
+ const example = `process({ action: "start", name: "${decision.suggestedName}", command: ${JSON.stringify(command)} })`;
99
+
100
+ return {
101
+ block: true,
102
+ reason: isBackground
103
+ ? `This bash command tries to run in the background. Use the process tool instead. Example: ${example}`
104
+ : `This bash command looks long-running and would block the conversation. Use the process tool instead. Example: ${example}`,
105
+ };
106
+ });
107
+ }
108
+
109
+ function classifySimpleCommand(
110
+ words: string[],
111
+ ): ManagedCommandDecision | undefined {
112
+ const [rawName, ...rawArgs] = words;
113
+ const name = basename(rawName).toLowerCase();
114
+ const args = rawArgs.map((arg) => arg.toLowerCase());
115
+
116
+ if (BACKGROUND_CMD_NAMES.has(name)) {
117
+ return {
118
+ kind: "background",
119
+ suggestedName: suggestProcessName(words),
120
+ };
121
+ }
122
+
123
+ if (isLongRunningCommand(rawName, rawArgs, name, args)) {
124
+ return {
125
+ kind: "long_running",
126
+ suggestedName: suggestProcessName(words),
127
+ };
128
+ }
129
+
130
+ return undefined;
131
+ }
132
+
133
+ function isLongRunningCommand(
134
+ rawName: string,
135
+ rawArgs: string[],
136
+ name: string,
137
+ args: string[],
138
+ ): boolean {
139
+ if (PACKAGE_MANAGERS.has(name)) {
140
+ const scriptName = getPackageManagerScript(args);
141
+ if (
142
+ (scriptName !== undefined && LONG_RUNNING_SCRIPT_NAMES.has(scriptName)) ||
143
+ hasAnyArg(args, WATCH_FLAGS)
144
+ ) {
145
+ return true;
146
+ }
147
+
148
+ if ((args[0] === "exec" || args[0] === "dlx") && rawArgs[1]) {
149
+ const execName = basename(rawArgs[1]).toLowerCase();
150
+ const execArgs = rawArgs.slice(2);
151
+ return isLongRunningCommand(
152
+ rawArgs[1],
153
+ execArgs,
154
+ execName,
155
+ execArgs.map((arg) => arg.toLowerCase()),
156
+ );
157
+ }
158
+
159
+ return false;
160
+ }
161
+
162
+ if (DIRECT_LONG_RUNNING_COMMANDS.has(name)) return true;
163
+
164
+ if (name === "next") return args[0] === "dev" || args[0] === "start";
165
+ if (name === "astro") return args[0] === "dev" || args[0] === "preview";
166
+ if (name === "webpack") return args.includes("serve");
167
+ if (name === "cargo") return args[0] === "watch";
168
+ if (name === "tail" || name === "journalctl") {
169
+ return hasAnyArg(args, FOLLOW_FLAGS);
170
+ }
171
+ if (name === "kubectl") {
172
+ return (
173
+ args[0] === "port-forward" ||
174
+ (args[0] === "logs" && hasAnyArg(args, FOLLOW_FLAGS))
175
+ );
176
+ }
177
+ if (name === "docker-compose") {
178
+ return args.includes("up") && !hasAnyArg(args, DETACH_FLAGS);
179
+ }
180
+ if (name === "docker") {
181
+ return (
182
+ args[0] === "compose" &&
183
+ args.includes("up") &&
184
+ !hasAnyArg(args, DETACH_FLAGS)
185
+ );
186
+ }
187
+ if (name === "ssh") return hasSshNoCommandFlag(rawArgs);
188
+ if (name === "python" || name === "python3") {
189
+ return args[0] === "-m" && args[1] === "http.server";
190
+ }
191
+ if (name === "vitest" || name === "jest") {
192
+ return hasAnyArg(args, WATCH_FLAGS);
193
+ }
194
+ if (name === "rails") return args[0] === "server" || args[0] === "s";
195
+ if (name === "npx" && rawArgs[0]) {
196
+ const execName = basename(rawArgs[0]).toLowerCase();
197
+ const execArgs = rawArgs.slice(1);
198
+ return isLongRunningCommand(
199
+ rawArgs[0],
200
+ execArgs,
201
+ execName,
202
+ execArgs.map((arg) => arg.toLowerCase()),
203
+ );
204
+ }
205
+
206
+ return (
207
+ looksLikeSuspiciousScript(rawName) ||
208
+ (SHELL_LAUNCHERS.has(name) && looksLikeSuspiciousScript(args[0]))
209
+ );
210
+ }
211
+
212
+ function getPackageManagerScript(args: string[]): string | undefined {
213
+ if (args.length === 0) return undefined;
214
+ if (args[0] === "run" || args[0] === "exec" || args[0] === "dlx") {
215
+ return args[1];
216
+ }
217
+ return args[0];
218
+ }
219
+
220
+ function hasSshNoCommandFlag(args: string[]): boolean {
221
+ return args.some((arg) => /^-[^-]*N/.test(arg));
222
+ }
223
+
224
+ function hasAnyArg(args: string[], values: Set<string>): boolean {
225
+ return args.some((arg) => values.has(arg));
226
+ }
227
+
228
+ function suggestProcessName(words: string[]): string {
229
+ const [rawName, ...rawArgs] = words;
230
+ const name = basename(rawName).toLowerCase();
231
+ const args = rawArgs.map((arg) => arg.toLowerCase());
232
+
233
+ if (PACKAGE_MANAGERS.has(name)) {
234
+ const scriptName = getPackageManagerScript(args);
235
+ if (scriptName) return sanitizeProcessName(scriptName);
236
+ }
237
+
238
+ if (name === "docker" || name === "docker-compose") return "compose";
239
+ if (name === "kubectl" && args[0] === "port-forward") return "port-forward";
240
+ if (name === "tail" || name === "journalctl") return "logs";
241
+ if (name === "npx" && rawArgs[0]) {
242
+ const execName = basename(rawArgs[0]).toLowerCase();
243
+ return sanitizeProcessName(execName);
244
+ }
245
+ if (SHELL_LAUNCHERS.has(name) && rawArgs[0]) {
246
+ const scriptName = sanitizeProcessName(rawArgs[0]);
247
+ if (scriptName !== "process") return scriptName;
248
+ }
249
+
250
+ return sanitizeProcessName(rawName);
251
+ }
252
+
253
+ function sanitizeProcessName(value: string): string {
254
+ const withoutExt = basename(value).replace(/\.(sh|bash|zsh|fish)$/i, "");
255
+ const cleaned = withoutExt
256
+ .toLowerCase()
257
+ .replace(/[^a-z0-9]+/g, "-")
258
+ .replace(/^-+|-+$/g, "");
259
+ return cleaned || "process";
260
+ }
261
+
262
+ function looksLikeSuspiciousScript(value: string | undefined): boolean {
263
+ if (!value) return false;
264
+ return SUSPICIOUS_SCRIPT_NAME.test(basename(value));
265
+ }
266
+
267
+ function analyzeManagedCommandFallback(
268
+ command: string,
269
+ ): ManagedCommandDecision | undefined {
270
+ if (BACKGROUND_PATTERN.test(command)) {
271
+ return {
272
+ kind: "background",
273
+ suggestedName: "background-process",
274
+ };
275
+ }
276
+
277
+ const lower = command.toLowerCase();
278
+ if (
279
+ /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|preview|watch)\b/.test(
280
+ lower,
281
+ ) ||
282
+ /\bdocker(?:-compose|\s+compose)\s+up\b/.test(lower) ||
283
+ /\bkubectl\s+port-forward\b/.test(lower) ||
284
+ /\b(?:tail|journalctl)\b.*(?:\s-f\b|\s-F\b|--follow\b)/.test(lower)
285
+ ) {
286
+ return {
287
+ kind: "long_running",
288
+ suggestedName: "process",
289
+ };
290
+ }
291
+
292
+ return undefined;
293
+ }
294
+
295
+ function findFirstCommandName(
296
+ command: string,
297
+ ast: Program,
298
+ ): string | undefined {
299
+ let suggested: string | undefined;
300
+
301
+ walkCommands(ast, (cmd) => {
302
+ const words = cmd.words?.map(wordToString).filter(Boolean) ?? [];
303
+ if (words.length === 0) return false;
304
+ suggested = suggestProcessName(words);
305
+ return true;
306
+ });
307
+
308
+ return suggested ?? sanitizeProcessName(command);
309
+ }
@@ -0,0 +1,8 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { ProcessManager } from "../manager";
3
+
4
+ export function setupCleanupHook(pi: ExtensionAPI, manager: ProcessManager) {
5
+ pi.on("session_shutdown", () => {
6
+ manager.cleanup();
7
+ });
8
+ }
@@ -0,0 +1,17 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { ResolvedProcessesConfig } from "../config";
3
+ import type { ProcessManager } from "../manager";
4
+ import { setupBackgroundBlocker } from "./background-blocker";
5
+ import { setupCleanupHook } from "./cleanup";
6
+
7
+ export function setupProcessesHooks(
8
+ pi: ExtensionAPI,
9
+ manager: ProcessManager,
10
+ config: ResolvedProcessesConfig,
11
+ ): void {
12
+ setupCleanupHook(pi, manager);
13
+
14
+ if (config.interception.blockBackgroundCommands) {
15
+ setupBackgroundBlocker(pi);
16
+ }
17
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { configLoader } from "./config";
3
+ import { setupProcessesHooks } from "./hooks";
4
+ import { ProcessManager } from "./manager";
5
+ import { setupProcessesTools } from "./tools";
6
+
7
+ export default async function (pi: ExtensionAPI) {
8
+ if (process.platform === "win32") {
9
+ pi.on("session_start", async (_event, ctx) => {
10
+ if (!ctx.hasUI) return;
11
+ ctx.ui.notify("processes extension not available on Windows", "warning");
12
+ });
13
+ return;
14
+ }
15
+
16
+ await configLoader.load();
17
+ const manager = new ProcessManager({
18
+ getConfiguredShellPath: () => configLoader.getConfig().execution.shellPath,
19
+ });
20
+
21
+ setupProcessesHooks(pi, manager, configLoader.getConfig());
22
+ setupProcessesTools(pi, manager);
23
+ }