@aliou/pi-processes 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.
@@ -0,0 +1,103 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@mariozechner/pi-coding-agent";
5
+ import { MESSAGE_TYPE_PROCESS_UPDATE } from "../constants";
6
+ import type { ProcessInfo, ProcessManager } from "../manager";
7
+
8
+ function formatRuntime(startTime: number, endTime: number | null): string {
9
+ const end = endTime ?? Date.now();
10
+ const ms = end - startTime;
11
+ const seconds = Math.floor(ms / 1000);
12
+ const minutes = Math.floor(seconds / 60);
13
+ const hours = Math.floor(minutes / 60);
14
+
15
+ if (hours > 0) {
16
+ return `${hours}h ${minutes % 60}m`;
17
+ }
18
+ if (minutes > 0) {
19
+ return `${minutes}m ${seconds % 60}s`;
20
+ }
21
+ return `${seconds}s`;
22
+ }
23
+
24
+ interface ProcessUpdateDetails {
25
+ processId: string;
26
+ processName: string;
27
+ command: string;
28
+ status: "exited" | "killed";
29
+ exitCode: number | null;
30
+ success: boolean;
31
+ runtime: string;
32
+ }
33
+
34
+ export function setupProcessEndHook(pi: ExtensionAPI, manager: ProcessManager) {
35
+ let latestContext: ExtensionContext | null = null;
36
+
37
+ // Capture context from session events
38
+ pi.on("session_start", async (_event, ctx) => {
39
+ latestContext = ctx;
40
+ });
41
+
42
+ pi.on("turn_start", async (_event, ctx) => {
43
+ latestContext = ctx;
44
+ });
45
+
46
+ pi.on("turn_end", async (_event, ctx) => {
47
+ latestContext = ctx;
48
+ });
49
+
50
+ // Set callback for process end events
51
+ manager.onProcessEnd = (info: ProcessInfo) => {
52
+ // Check notification preferences
53
+ const shouldNotify =
54
+ (info.status === "killed" && info.notifyOnKill) ||
55
+ (info.status === "exited" && info.success && info.notifyOnSuccess) ||
56
+ (info.status === "exited" && !info.success && info.notifyOnFailure);
57
+
58
+ const runtime = formatRuntime(info.startTime, info.endTime);
59
+
60
+ // Build notification message
61
+ let message: string;
62
+ let level: "info" | "error" | "warning";
63
+
64
+ if (info.status === "killed") {
65
+ message = `Process '${info.name}' was terminated (${runtime})`;
66
+ level = "warning";
67
+ } else if (info.success) {
68
+ message = `Process '${info.name}' completed successfully (${runtime})`;
69
+ level = "info";
70
+ } else {
71
+ message = `Process '${info.name}' crashed with exit code ${info.exitCode ?? "?"} (${runtime})`;
72
+ level = "error";
73
+ }
74
+
75
+ // Always notify user via UI
76
+ if (latestContext?.hasUI) {
77
+ latestContext.ui.notify(message, level);
78
+ }
79
+
80
+ // Only send message to agent if notification preferences allow
81
+ if (shouldNotify) {
82
+ const details: ProcessUpdateDetails = {
83
+ processId: info.id,
84
+ processName: info.name,
85
+ command: info.command,
86
+ status: info.status as "exited" | "killed",
87
+ exitCode: info.exitCode,
88
+ success: info.success ?? false,
89
+ runtime,
90
+ };
91
+
92
+ pi.sendMessage(
93
+ {
94
+ customType: MESSAGE_TYPE_PROCESS_UPDATE,
95
+ content: message,
96
+ display: true,
97
+ details,
98
+ },
99
+ { triggerTurn: false },
100
+ );
101
+ }
102
+ };
103
+ }
@@ -0,0 +1,144 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@mariozechner/pi-coding-agent";
5
+ import type { ProcessInfo, ProcessManager } from "../manager";
6
+
7
+ const WIDGET_ID = "processes-status";
8
+
9
+ function formatRuntime(startTime: number, endTime: number | null): string {
10
+ const end = endTime ?? Date.now();
11
+ const ms = end - startTime;
12
+ const seconds = Math.floor(ms / 1000);
13
+ const minutes = Math.floor(seconds / 60);
14
+ const hours = Math.floor(minutes / 60);
15
+
16
+ if (hours > 0) {
17
+ return `${hours}h${minutes % 60}m`;
18
+ }
19
+ if (minutes > 0) {
20
+ return `${minutes}m${seconds % 60}s`;
21
+ }
22
+ return `${seconds}s`;
23
+ }
24
+
25
+ function formatProcessStatus(
26
+ proc: ProcessInfo,
27
+ theme: ExtensionContext["ui"]["theme"],
28
+ ): string {
29
+ const runtime = formatRuntime(proc.startTime, proc.endTime);
30
+ const name =
31
+ proc.name.length > 20 ? `${proc.name.slice(0, 17)}...` : proc.name;
32
+
33
+ if (proc.status === "running") {
34
+ return `${theme.fg("accent", name)} ${theme.fg("dim", runtime)}`;
35
+ }
36
+ if (proc.status === "killed") {
37
+ return `${theme.fg("warning", name)} ${theme.fg("dim", "killed")}`;
38
+ }
39
+ if (proc.success) {
40
+ return `${theme.fg("dim", name)} ${theme.fg("success", "done")}`;
41
+ }
42
+ return `${theme.fg("error", name)} ${theme.fg("error", `exit(${proc.exitCode ?? "?"})`)}`;
43
+ }
44
+
45
+ function renderWidget(
46
+ processes: ProcessInfo[],
47
+ theme: ExtensionContext["ui"]["theme"],
48
+ ): string[] {
49
+ if (processes.length === 0) {
50
+ return [];
51
+ }
52
+
53
+ const running = processes.filter((p) => p.status === "running");
54
+ const finished = processes.filter((p) => p.status !== "running");
55
+
56
+ const parts: string[] = [];
57
+
58
+ // Show running processes first
59
+ for (const proc of running) {
60
+ parts.push(formatProcessStatus(proc, theme));
61
+ }
62
+
63
+ // Show finished processes (most recent first, limit to 3)
64
+ const recentFinished = finished
65
+ .sort((a, b) => (b.endTime ?? 0) - (a.endTime ?? 0))
66
+ .slice(0, 3);
67
+
68
+ for (const proc of recentFinished) {
69
+ parts.push(formatProcessStatus(proc, theme));
70
+ }
71
+
72
+ // If there are more finished processes, show count
73
+ const hiddenCount = finished.length - recentFinished.length;
74
+ if (hiddenCount > 0) {
75
+ parts.push(theme.fg("dim", `+${hiddenCount} more`));
76
+ }
77
+
78
+ const prefix = theme.fg("dim", "processes: ");
79
+ return [prefix + parts.join(theme.fg("dim", " | "))];
80
+ }
81
+
82
+ export function setupProcessWidget(pi: ExtensionAPI, manager: ProcessManager) {
83
+ let latestContext: ExtensionContext | null = null;
84
+ let refreshInterval: ReturnType<typeof setInterval> | null = null;
85
+
86
+ function updateWidget() {
87
+ if (!latestContext?.hasUI) return;
88
+
89
+ const processes = manager.list();
90
+ const lines = renderWidget(processes, latestContext.ui.theme);
91
+
92
+ if (lines.length === 0) {
93
+ latestContext.ui.setWidget(WIDGET_ID, undefined);
94
+ } else {
95
+ latestContext.ui.setWidget(WIDGET_ID, lines, {
96
+ placement: "belowEditor",
97
+ });
98
+ }
99
+ }
100
+
101
+ function startRefresh() {
102
+ if (refreshInterval) return;
103
+ refreshInterval = setInterval(() => {
104
+ const hasRunning = manager.list().some((p) => p.status === "running");
105
+ if (hasRunning) {
106
+ updateWidget();
107
+ }
108
+ }, 1000);
109
+ }
110
+
111
+ function stopRefresh() {
112
+ if (refreshInterval) {
113
+ clearInterval(refreshInterval);
114
+ refreshInterval = null;
115
+ }
116
+ }
117
+
118
+ // Capture context and update widget
119
+ pi.on("session_start", async (_event, ctx) => {
120
+ latestContext = ctx;
121
+ updateWidget();
122
+ startRefresh();
123
+ });
124
+
125
+ pi.on("session_switch", async (_event, ctx) => {
126
+ latestContext = ctx;
127
+ updateWidget();
128
+ });
129
+
130
+ pi.on("session_shutdown", async () => {
131
+ stopRefresh();
132
+ });
133
+
134
+ // Chain into process end callback
135
+ const originalOnProcessEnd = manager.onProcessEnd;
136
+ manager.onProcessEnd = (info) => {
137
+ originalOnProcessEnd?.call(manager, info);
138
+ updateWidget();
139
+ };
140
+
141
+ return {
142
+ update: updateWidget,
143
+ };
144
+ }
package/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { setupProcessesCommands } from "./commands";
3
+ import { setupProcessesHooks } from "./hooks";
4
+ import { ProcessManager } from "./manager";
5
+ import { setupProcessesTools } from "./tools";
6
+
7
+ export default function (pi: ExtensionAPI) {
8
+ const manager = new ProcessManager();
9
+
10
+ setupProcessesHooks(pi, manager);
11
+ setupProcessesTools(pi, manager);
12
+ setupProcessesCommands(pi, manager);
13
+ }
package/manager.ts ADDED
@@ -0,0 +1,405 @@
1
+ import { type ChildProcess, spawn } from "node:child_process";
2
+ import {
3
+ appendFileSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ statSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ export interface ProcessInfo {
13
+ id: string;
14
+ name: string; // Friendly name for display
15
+ pid: number;
16
+ command: string;
17
+ cwd: string;
18
+ startTime: number;
19
+ endTime: number | null;
20
+ status: "running" | "exited" | "killed";
21
+ exitCode: number | null;
22
+ success: boolean | null; // null if running, true if exit code 0, false otherwise
23
+ stdoutFile: string;
24
+ stderrFile: string;
25
+ notifyOnSuccess: boolean;
26
+ notifyOnFailure: boolean;
27
+ notifyOnKill: boolean;
28
+ }
29
+
30
+ interface ManagedProcess extends ProcessInfo {
31
+ process: ChildProcess;
32
+ }
33
+
34
+ // Generate a friendly name from command
35
+ function inferName(command: string): string {
36
+ const cmd = command.toLowerCase();
37
+
38
+ // Dev servers
39
+ if (cmd.includes("dev") && cmd.includes("backend")) return "backend-dev";
40
+ if (cmd.includes("dev") && cmd.includes("frontend")) return "frontend-dev";
41
+ if (cmd.includes("dev") && cmd.includes("api")) return "api-dev";
42
+ if (
43
+ cmd.includes("pnpm dev") ||
44
+ cmd.includes("npm run dev") ||
45
+ cmd.includes("yarn dev")
46
+ )
47
+ return "dev-server";
48
+ if (cmd.includes("vite")) return "vite-dev";
49
+ if (cmd.includes("next dev")) return "next-dev";
50
+
51
+ // Build
52
+ if (cmd.includes("build")) return "build";
53
+ if (cmd.includes("compile")) return "compile";
54
+
55
+ // Tests
56
+ if (cmd.includes("test") || cmd.includes("jest") || cmd.includes("vitest"))
57
+ return "tests";
58
+
59
+ // Watch
60
+ if (cmd.includes("watch")) return "watcher";
61
+
62
+ // Logs
63
+ if (cmd.includes("tail")) return "log-tail";
64
+
65
+ // Docker
66
+ if (cmd.includes("docker-compose") || cmd.includes("docker compose"))
67
+ return "docker";
68
+
69
+ // Database
70
+ if (
71
+ cmd.includes("postgres") ||
72
+ cmd.includes("mysql") ||
73
+ cmd.includes("mongo")
74
+ )
75
+ return "database";
76
+
77
+ // Extract first meaningful word
78
+ const words = command.split(/\s+/);
79
+ const firstWord = (words[0] ?? "process")
80
+ .replace(/^\.\//, "")
81
+ .replace(/\.(sh|js|ts|py)$/, "");
82
+ return firstWord.slice(0, 20);
83
+ }
84
+
85
+ export class ProcessManager {
86
+ private processes: Map<string, ManagedProcess> = new Map();
87
+ private counter = 0;
88
+ private logDir: string;
89
+ onProcessEnd?: (info: ProcessInfo) => void;
90
+
91
+ constructor() {
92
+ this.logDir = join(tmpdir(), `pi-processes-${Date.now()}`);
93
+ mkdirSync(this.logDir, { recursive: true });
94
+ }
95
+
96
+ private emitProcessEnd(info: ProcessInfo): void {
97
+ this.onProcessEnd?.(info);
98
+ }
99
+
100
+ start(
101
+ command: string,
102
+ cwd: string,
103
+ name?: string,
104
+ options?: {
105
+ notifyOnSuccess?: boolean;
106
+ notifyOnFailure?: boolean;
107
+ notifyOnKill?: boolean;
108
+ },
109
+ ): ProcessInfo {
110
+ const id = `proc_${++this.counter}`;
111
+ const friendlyName = name || inferName(command);
112
+ const stdoutFile = join(this.logDir, `${id}-stdout.log`);
113
+ const stderrFile = join(this.logDir, `${id}-stderr.log`);
114
+
115
+ appendFileSync(stdoutFile, "");
116
+ appendFileSync(stderrFile, "");
117
+
118
+ const child = spawn(command, {
119
+ cwd,
120
+ shell: true,
121
+ stdio: ["ignore", "pipe", "pipe"],
122
+ detached: false,
123
+ });
124
+
125
+ const managed: ManagedProcess = {
126
+ id,
127
+ name: friendlyName,
128
+ pid: child.pid ?? -1,
129
+ command,
130
+ cwd,
131
+ startTime: Date.now(),
132
+ endTime: null,
133
+ status: "running",
134
+ exitCode: null,
135
+ success: null,
136
+ stdoutFile,
137
+ stderrFile,
138
+ notifyOnSuccess: options?.notifyOnSuccess ?? false,
139
+ notifyOnFailure: options?.notifyOnFailure ?? true,
140
+ notifyOnKill: options?.notifyOnKill ?? false,
141
+ process: child,
142
+ };
143
+
144
+ child.stdout?.on("data", (data: Buffer) => {
145
+ try {
146
+ appendFileSync(stdoutFile, data);
147
+ } catch {
148
+ // Ignore write errors
149
+ }
150
+ });
151
+
152
+ child.stderr?.on("data", (data: Buffer) => {
153
+ try {
154
+ appendFileSync(stderrFile, data);
155
+ } catch {
156
+ // Ignore write errors
157
+ }
158
+ });
159
+
160
+ child.on("close", (code, signal) => {
161
+ // Already handled (e.g., by checkRunningProcesses detecting external kill)
162
+ if (managed.status !== "running") {
163
+ return;
164
+ }
165
+ managed.exitCode = code;
166
+ managed.endTime = Date.now();
167
+ managed.success = code === 0;
168
+ managed.status = signal ? "killed" : "exited";
169
+ this.emitProcessEnd(this.toProcessInfo(managed));
170
+ });
171
+
172
+ child.on("error", (err) => {
173
+ try {
174
+ appendFileSync(stderrFile, `Process error: ${err.message}\n`);
175
+ } catch {
176
+ // Ignore
177
+ }
178
+ managed.status = "exited";
179
+ managed.exitCode = -1;
180
+ managed.success = false;
181
+ managed.endTime = Date.now();
182
+ this.emitProcessEnd(this.toProcessInfo(managed));
183
+ });
184
+
185
+ this.processes.set(id, managed);
186
+
187
+ return this.toProcessInfo(managed);
188
+ }
189
+
190
+ list(): ProcessInfo[] {
191
+ // Check if any "running" processes have actually exited
192
+ this.checkRunningProcesses();
193
+ return Array.from(this.processes.values()).map((p) =>
194
+ this.toProcessInfo(p),
195
+ );
196
+ }
197
+
198
+ get(id: string): ProcessInfo | null {
199
+ this.checkRunningProcesses();
200
+ const managed = this.processes.get(id);
201
+ return managed ? this.toProcessInfo(managed) : null;
202
+ }
203
+
204
+ // Find by ID or name (partial match)
205
+ find(query: string): ProcessInfo | null {
206
+ this.checkRunningProcesses();
207
+
208
+ // Exact ID match first
209
+ const byId = this.processes.get(query);
210
+ if (byId) return this.toProcessInfo(byId);
211
+
212
+ // Search by name (case insensitive, partial match)
213
+ const queryLower = query.toLowerCase();
214
+ for (const managed of this.processes.values()) {
215
+ if (managed.name.toLowerCase().includes(queryLower)) {
216
+ return this.toProcessInfo(managed);
217
+ }
218
+ if (managed.command.toLowerCase().includes(queryLower)) {
219
+ return this.toProcessInfo(managed);
220
+ }
221
+ }
222
+ return null;
223
+ }
224
+
225
+ getOutput(
226
+ id: string,
227
+ tailLines = 100,
228
+ ): { stdout: string[]; stderr: string[]; status: string } | null {
229
+ const managed = this.processes.get(id);
230
+ if (!managed) return null;
231
+
232
+ return {
233
+ stdout: this.readTailLines(managed.stdoutFile, tailLines),
234
+ stderr: this.readTailLines(managed.stderrFile, tailLines),
235
+ status: managed.status,
236
+ };
237
+ }
238
+
239
+ getFullOutput(id: string): { stdout: string; stderr: string } | null {
240
+ const managed = this.processes.get(id);
241
+ if (!managed) return null;
242
+
243
+ try {
244
+ return {
245
+ stdout: readFileSync(managed.stdoutFile, "utf-8"),
246
+ stderr: readFileSync(managed.stderrFile, "utf-8"),
247
+ };
248
+ } catch {
249
+ return { stdout: "", stderr: "" };
250
+ }
251
+ }
252
+
253
+ getLogFiles(id: string): { stdoutFile: string; stderrFile: string } | null {
254
+ const managed = this.processes.get(id);
255
+ if (!managed) return null;
256
+ return {
257
+ stdoutFile: managed.stdoutFile,
258
+ stderrFile: managed.stderrFile,
259
+ };
260
+ }
261
+
262
+ kill(id: string): boolean {
263
+ const managed = this.processes.get(id);
264
+ if (!managed) return false;
265
+
266
+ if (managed.status !== "running") {
267
+ return true;
268
+ }
269
+
270
+ // Disable kill notification since this is intentional
271
+ managed.notifyOnKill = false;
272
+
273
+ managed.status = "killed";
274
+ managed.endTime = Date.now();
275
+ managed.success = false;
276
+
277
+ try {
278
+ managed.process.kill("SIGTERM");
279
+
280
+ setTimeout(() => {
281
+ try {
282
+ if (!managed.process.killed) {
283
+ managed.process.kill("SIGKILL");
284
+ }
285
+ } catch {
286
+ // Process may already be dead
287
+ }
288
+ }, 3000);
289
+
290
+ return true;
291
+ } catch {
292
+ return false;
293
+ }
294
+ }
295
+
296
+ // Clear finished processes (not running)
297
+ clearFinished(): number {
298
+ let cleared = 0;
299
+ for (const [id, managed] of this.processes) {
300
+ if (managed.status !== "running") {
301
+ // Clean up log files
302
+ try {
303
+ rmSync(managed.stdoutFile, { force: true });
304
+ rmSync(managed.stderrFile, { force: true });
305
+ } catch {
306
+ // Ignore
307
+ }
308
+ this.processes.delete(id);
309
+ cleared++;
310
+ }
311
+ }
312
+ return cleared;
313
+ }
314
+
315
+ killAll(): void {
316
+ for (const [id] of this.processes) {
317
+ this.kill(id);
318
+ }
319
+ }
320
+
321
+ // Check if a PID is still alive (works across platforms)
322
+ private isProcessAlive(pid: number): boolean {
323
+ try {
324
+ // Signal 0 checks if process exists without actually sending a signal
325
+ process.kill(pid, 0);
326
+ return true;
327
+ } catch (error) {
328
+ // If error code is ESRCH, process doesn't exist
329
+ // If error code is EPERM, process exists but we don't have permission (still alive)
330
+ const err = error as NodeJS.ErrnoException;
331
+ return err.code === "EPERM";
332
+ }
333
+ }
334
+
335
+ // Check all running processes and update status if they've exited
336
+ private checkRunningProcesses(): void {
337
+ for (const managed of this.processes.values()) {
338
+ if (managed.status === "running" && !this.isProcessAlive(managed.pid)) {
339
+ // Process is no longer alive but we didn't get the close event yet
340
+ // Mark it as exited with unknown exit code
341
+ managed.status = "exited";
342
+ managed.exitCode = null;
343
+ managed.success = false;
344
+ managed.endTime = Date.now();
345
+ this.emitProcessEnd(this.toProcessInfo(managed));
346
+ }
347
+ }
348
+ }
349
+
350
+ cleanup(): void {
351
+ this.killAll();
352
+ try {
353
+ rmSync(this.logDir, { recursive: true, force: true });
354
+ } catch {
355
+ // Ignore cleanup errors
356
+ }
357
+ }
358
+
359
+ private readTailLines(filePath: string, lines: number): string[] {
360
+ try {
361
+ const content = readFileSync(filePath, "utf-8");
362
+ const allLines = content.split("\n");
363
+ if (allLines.length > 0 && allLines[allLines.length - 1] === "") {
364
+ allLines.pop();
365
+ }
366
+ return allLines.slice(-lines);
367
+ } catch {
368
+ return [];
369
+ }
370
+ }
371
+
372
+ getFileSize(id: string): { stdout: number; stderr: number } | null {
373
+ const managed = this.processes.get(id);
374
+ if (!managed) return null;
375
+
376
+ try {
377
+ return {
378
+ stdout: statSync(managed.stdoutFile).size,
379
+ stderr: statSync(managed.stderrFile).size,
380
+ };
381
+ } catch {
382
+ return { stdout: 0, stderr: 0 };
383
+ }
384
+ }
385
+
386
+ private toProcessInfo(managed: ManagedProcess): ProcessInfo {
387
+ return {
388
+ id: managed.id,
389
+ name: managed.name,
390
+ pid: managed.pid,
391
+ command: managed.command,
392
+ cwd: managed.cwd,
393
+ startTime: managed.startTime,
394
+ endTime: managed.endTime,
395
+ status: managed.status,
396
+ exitCode: managed.exitCode,
397
+ success: managed.success,
398
+ stdoutFile: managed.stdoutFile,
399
+ stderrFile: managed.stderrFile,
400
+ notifyOnSuccess: managed.notifyOnSuccess,
401
+ notifyOnFailure: managed.notifyOnFailure,
402
+ notifyOnKill: managed.notifyOnKill,
403
+ };
404
+ }
405
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@aliou/pi-processes",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "private": false,
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "pi",
10
+ "processes"
11
+ ],
12
+ "pi": {
13
+ "extensions": [
14
+ "./index.ts"
15
+ ]
16
+ },
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "files": [
21
+ "*.ts",
22
+ "commands",
23
+ "hooks",
24
+ "tools",
25
+ "README.md"
26
+ ],
27
+ "dependencies": {
28
+ "@sinclair/typebox": "^0.34.41"
29
+ },
30
+ "peerDependencies": {
31
+ "@mariozechner/pi-ai": ">=0.49.0",
32
+ "@mariozechner/pi-coding-agent": ">=0.49.0",
33
+ "@mariozechner/pi-tui": ">=0.49.0"
34
+ }
35
+ }