@aliou/pi-processes 0.1.1 → 0.2.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/manager.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { type ChildProcess, spawn } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
2
3
  import {
3
4
  appendFileSync,
4
5
  mkdirSync,
@@ -9,122 +10,134 @@ import {
9
10
  import { tmpdir } from "node:os";
10
11
  import { join } from "node:path";
11
12
 
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
- }
13
+ import {
14
+ type KillResult,
15
+ LIVE_STATUSES,
16
+ type ManagerEvent,
17
+ type ProcessInfo,
18
+ type ProcessStatus,
19
+ type StartOptions,
20
+ } from "./constants";
21
+ import { isProcessGroupAlive, killProcessGroup } from "./utils";
29
22
 
30
23
  interface ManagedProcess extends ProcessInfo {
31
24
  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);
25
+ lastSignalSent: NodeJS.Signals | null;
83
26
  }
84
27
 
85
28
  export class ProcessManager {
86
29
  private processes: Map<string, ManagedProcess> = new Map();
87
30
  private counter = 0;
88
31
  private logDir: string;
89
- onProcessEnd?: (info: ProcessInfo) => void;
32
+ private events = new EventEmitter();
33
+ private watcher: ReturnType<typeof setInterval> | null = null;
90
34
 
91
35
  constructor() {
92
36
  this.logDir = join(tmpdir(), `pi-processes-${Date.now()}`);
93
37
  mkdirSync(this.logDir, { recursive: true });
94
38
  }
95
39
 
96
- private emitProcessEnd(info: ProcessInfo): void {
97
- this.onProcessEnd?.(info);
40
+ onEvent(listener: (event: ManagerEvent) => void): () => void {
41
+ this.events.on("event", listener);
42
+ return () => this.events.off("event", listener);
43
+ }
44
+
45
+ private emit(event: ManagerEvent): void {
46
+ this.events.emit("event", event);
47
+ }
48
+
49
+ private transition(managed: ManagedProcess, next: ProcessStatus): void {
50
+ if (managed.status === next) return;
51
+ const prev = managed.status;
52
+ managed.status = next;
53
+
54
+ this.emit({
55
+ type: "process_status_changed",
56
+ info: this.toProcessInfo(managed),
57
+ prev,
58
+ });
59
+
60
+ if (next === "exited" || next === "killed") {
61
+ this.emit({ type: "process_ended", info: this.toProcessInfo(managed) });
62
+ }
63
+
64
+ this.ensureWatcherRunning();
65
+ this.stopWatcherIfIdle();
66
+ }
67
+
68
+ private ensureWatcherRunning(): void {
69
+ if (this.watcher) return;
70
+ if (!this.hasAliveishProcesses()) return;
71
+
72
+ this.watcher = setInterval(() => {
73
+ this.livenessTick();
74
+ }, 5000);
75
+ }
76
+
77
+ private stopWatcherIfIdle(): void {
78
+ if (!this.watcher) return;
79
+ if (this.hasAliveishProcesses()) return;
80
+
81
+ clearInterval(this.watcher);
82
+ this.watcher = null;
83
+ }
84
+
85
+ private hasAliveishProcesses(): boolean {
86
+ for (const p of this.processes.values()) {
87
+ if (LIVE_STATUSES.has(p.status)) return true;
88
+ }
89
+ return false;
90
+ }
91
+
92
+ private livenessTick(): void {
93
+ for (const managed of this.processes.values()) {
94
+ if (!LIVE_STATUSES.has(managed.status)) continue;
95
+ if (!managed.pid || managed.pid <= 0) continue;
96
+
97
+ const alive = isProcessGroupAlive(managed.pid);
98
+ if (alive) continue;
99
+
100
+ if (!managed.endTime) {
101
+ managed.endTime = Date.now();
102
+ }
103
+
104
+ if (managed.lastSignalSent) {
105
+ managed.success = false;
106
+ managed.exitCode = null;
107
+ this.transition(managed, "killed");
108
+ } else {
109
+ managed.success = false;
110
+ managed.exitCode = null;
111
+ this.transition(managed, "exited");
112
+ }
113
+ }
98
114
  }
99
115
 
100
116
  start(
117
+ name: string,
101
118
  command: string,
102
119
  cwd: string,
103
- name?: string,
104
- options?: {
105
- notifyOnSuccess?: boolean;
106
- notifyOnFailure?: boolean;
107
- notifyOnKill?: boolean;
108
- },
120
+ options?: StartOptions,
109
121
  ): ProcessInfo {
110
122
  const id = `proc_${++this.counter}`;
111
- const friendlyName = name || inferName(command);
112
123
  const stdoutFile = join(this.logDir, `${id}-stdout.log`);
113
124
  const stderrFile = join(this.logDir, `${id}-stderr.log`);
114
125
 
115
126
  appendFileSync(stdoutFile, "");
116
127
  appendFileSync(stderrFile, "");
117
128
 
118
- const child = spawn(command, {
129
+ const child = spawn("/bin/bash", ["-lc", command], {
119
130
  cwd,
120
- shell: true,
131
+ env: process.env,
121
132
  stdio: ["ignore", "pipe", "pipe"],
122
- detached: false,
133
+ detached: true,
123
134
  });
124
135
 
136
+ child.unref();
137
+
125
138
  const managed: ManagedProcess = {
126
139
  id,
127
- name: friendlyName,
140
+ name,
128
141
  pid: child.pid ?? -1,
129
142
  command,
130
143
  cwd,
@@ -139,13 +152,29 @@ export class ProcessManager {
139
152
  notifyOnFailure: options?.notifyOnFailure ?? true,
140
153
  notifyOnKill: options?.notifyOnKill ?? false,
141
154
  process: child,
155
+ lastSignalSent: null,
142
156
  };
143
157
 
158
+ this.processes.set(id, managed);
159
+
160
+ if (!child.pid) {
161
+ try {
162
+ appendFileSync(stderrFile, "Spawn error: missing pid\n");
163
+ } catch {
164
+ // Ignore
165
+ }
166
+ managed.exitCode = -1;
167
+ managed.success = false;
168
+ managed.endTime = Date.now();
169
+ this.transition(managed, "exited");
170
+ return this.toProcessInfo(managed);
171
+ }
172
+
144
173
  child.stdout?.on("data", (data: Buffer) => {
145
174
  try {
146
175
  appendFileSync(stdoutFile, data);
147
176
  } catch {
148
- // Ignore write errors
177
+ // Ignore
149
178
  }
150
179
  });
151
180
 
@@ -153,20 +182,22 @@ export class ProcessManager {
153
182
  try {
154
183
  appendFileSync(stderrFile, data);
155
184
  } catch {
156
- // Ignore write errors
185
+ // Ignore
157
186
  }
158
187
  });
159
188
 
160
189
  child.on("close", (code, signal) => {
161
- // Already handled (e.g., by checkRunningProcesses detecting external kill)
162
- if (managed.status !== "running") {
163
- return;
164
- }
190
+ if (managed.endTime) return;
191
+
165
192
  managed.exitCode = code;
166
193
  managed.endTime = Date.now();
167
194
  managed.success = code === 0;
168
- managed.status = signal ? "killed" : "exited";
169
- this.emitProcessEnd(this.toProcessInfo(managed));
195
+
196
+ if (signal) {
197
+ this.transition(managed, "killed");
198
+ } else {
199
+ this.transition(managed, "exited");
200
+ }
170
201
  });
171
202
 
172
203
  child.on("error", (err) => {
@@ -175,41 +206,36 @@ export class ProcessManager {
175
206
  } catch {
176
207
  // Ignore
177
208
  }
178
- managed.status = "exited";
179
- managed.exitCode = -1;
180
- managed.success = false;
181
- managed.endTime = Date.now();
182
- this.emitProcessEnd(this.toProcessInfo(managed));
209
+
210
+ if (!managed.endTime) {
211
+ managed.exitCode = -1;
212
+ managed.success = false;
213
+ managed.endTime = Date.now();
214
+ this.transition(managed, "exited");
215
+ }
183
216
  });
184
217
 
185
- this.processes.set(id, managed);
218
+ this.emit({ type: "process_started", info: this.toProcessInfo(managed) });
219
+ this.ensureWatcherRunning();
186
220
 
187
221
  return this.toProcessInfo(managed);
188
222
  }
189
223
 
190
224
  list(): ProcessInfo[] {
191
- // Check if any "running" processes have actually exited
192
- this.checkRunningProcesses();
193
225
  return Array.from(this.processes.values()).map((p) =>
194
226
  this.toProcessInfo(p),
195
227
  );
196
228
  }
197
229
 
198
230
  get(id: string): ProcessInfo | null {
199
- this.checkRunningProcesses();
200
231
  const managed = this.processes.get(id);
201
232
  return managed ? this.toProcessInfo(managed) : null;
202
233
  }
203
234
 
204
- // Find by ID or name (partial match)
205
235
  find(query: string): ProcessInfo | null {
206
- this.checkRunningProcesses();
207
-
208
- // Exact ID match first
209
236
  const byId = this.processes.get(query);
210
237
  if (byId) return this.toProcessInfo(byId);
211
238
 
212
- // Search by name (case insensitive, partial match)
213
239
  const queryLower = query.toLowerCase();
214
240
  for (const managed of this.processes.values()) {
215
241
  if (managed.name.toLowerCase().includes(queryLower)) {
@@ -259,113 +285,145 @@ export class ProcessManager {
259
285
  };
260
286
  }
261
287
 
262
- kill(id: string): boolean {
288
+ async kill(
289
+ id: string,
290
+ opts?: { signal?: NodeJS.Signals; timeoutMs?: number },
291
+ ): Promise<KillResult> {
263
292
  const managed = this.processes.get(id);
264
- if (!managed) return false;
265
-
266
- if (managed.status !== "running") {
267
- return true;
293
+ if (!managed) {
294
+ return {
295
+ ok: false,
296
+ info: {
297
+ id,
298
+ name: "(unknown)",
299
+ pid: -1,
300
+ command: "",
301
+ cwd: "",
302
+ startTime: 0,
303
+ endTime: null,
304
+ status: "exited",
305
+ exitCode: null,
306
+ success: false,
307
+ stdoutFile: "",
308
+ stderrFile: "",
309
+ notifyOnSuccess: false,
310
+ notifyOnFailure: true,
311
+ notifyOnKill: false,
312
+ },
313
+ reason: "not_found",
314
+ };
268
315
  }
269
316
 
270
- // Disable kill notification since this is intentional
317
+ const signal = opts?.signal ?? "SIGTERM";
318
+ const timeoutMs = opts?.timeoutMs ?? 3000;
319
+
271
320
  managed.notifyOnKill = false;
272
321
 
273
- managed.status = "killed";
274
- managed.endTime = Date.now();
275
- managed.success = false;
322
+ if (!LIVE_STATUSES.has(managed.status)) {
323
+ return { ok: true, info: this.toProcessInfo(managed) };
324
+ }
325
+
326
+ this.transition(managed, "terminating");
276
327
 
277
328
  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;
329
+ killProcessGroup(managed.pid, signal);
330
+ managed.lastSignalSent = signal;
331
+ } catch (error) {
332
+ const err = error as NodeJS.ErrnoException;
333
+ if (err.code !== "EPERM") {
334
+ return {
335
+ ok: false,
336
+ info: this.toProcessInfo(managed),
337
+ reason: "error",
338
+ };
339
+ }
340
+ }
341
+
342
+ const graceMs = signal === "SIGKILL" ? 200 : timeoutMs;
343
+
344
+ await new Promise((r) => setTimeout(r, graceMs));
345
+
346
+ const alive = isProcessGroupAlive(managed.pid);
347
+
348
+ if (alive) {
349
+ this.transition(managed, "terminate_timeout");
350
+ return {
351
+ ok: false,
352
+ info: this.toProcessInfo(managed),
353
+ reason: "timeout",
354
+ };
293
355
  }
356
+
357
+ if (!managed.endTime) {
358
+ managed.endTime = Date.now();
359
+ managed.exitCode = null;
360
+ managed.success = false;
361
+ }
362
+
363
+ this.transition(managed, "killed");
364
+ return { ok: true, info: this.toProcessInfo(managed) };
294
365
  }
295
366
 
296
- // Clear finished processes (not running)
297
367
  clearFinished(): number {
298
368
  let cleared = 0;
299
369
  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++;
370
+ if (LIVE_STATUSES.has(managed.status)) {
371
+ continue;
310
372
  }
373
+
374
+ try {
375
+ rmSync(managed.stdoutFile, { force: true });
376
+ rmSync(managed.stderrFile, { force: true });
377
+ } catch {
378
+ // Ignore
379
+ }
380
+
381
+ this.processes.delete(id);
382
+ cleared++;
311
383
  }
312
- return cleared;
313
- }
314
384
 
315
- killAll(): void {
316
- for (const [id] of this.processes) {
317
- this.kill(id);
385
+ if (cleared > 0) {
386
+ this.emit({ type: "processes_changed" });
318
387
  }
388
+
389
+ this.stopWatcherIfIdle();
390
+ return cleared;
319
391
  }
320
392
 
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";
393
+ shutdownKillAll(): void {
394
+ for (const p of this.processes.values()) {
395
+ if (!LIVE_STATUSES.has(p.status)) continue;
396
+ try {
397
+ killProcessGroup(p.pid, "SIGKILL");
398
+ } catch {
399
+ // Ignore - process may already be dead
400
+ }
332
401
  }
333
402
  }
334
403
 
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
- }
404
+ stopWatcher(): void {
405
+ if (this.watcher) {
406
+ clearInterval(this.watcher);
407
+ this.watcher = null;
347
408
  }
348
409
  }
349
410
 
350
411
  cleanup(): void {
351
- this.killAll();
352
- try {
353
- rmSync(this.logDir, { recursive: true, force: true });
354
- } catch {
355
- // Ignore cleanup errors
412
+ this.stopWatcher();
413
+
414
+ for (const p of this.processes.values()) {
415
+ if (!LIVE_STATUSES.has(p.status)) continue;
416
+ try {
417
+ killProcessGroup(p.pid, "SIGKILL");
418
+ } catch {
419
+ // Ignore
420
+ }
356
421
  }
357
- }
358
422
 
359
- private readTailLines(filePath: string, lines: number): string[] {
360
423
  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);
424
+ rmSync(this.logDir, { recursive: true, force: true });
367
425
  } catch {
368
- return [];
426
+ // Ignore
369
427
  }
370
428
  }
371
429
 
@@ -383,6 +441,19 @@ export class ProcessManager {
383
441
  }
384
442
  }
385
443
 
444
+ private readTailLines(filePath: string, lines: number): string[] {
445
+ try {
446
+ const content = readFileSync(filePath, "utf-8");
447
+ const allLines = content.split("\n");
448
+ if (allLines.length > 0 && allLines[allLines.length - 1] === "") {
449
+ allLines.pop();
450
+ }
451
+ return allLines.slice(-lines);
452
+ } catch {
453
+ return [];
454
+ }
455
+ }
456
+
386
457
  private toProcessInfo(managed: ManagedProcess): ProcessInfo {
387
458
  return {
388
459
  id: managed.id,
@@ -403,3 +474,5 @@ export class ProcessManager {
403
474
  };
404
475
  }
405
476
  }
477
+
478
+ export type { ProcessInfo, ProcessStatus, ManagerEvent, KillResult };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-processes",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "keywords": [
@@ -9,6 +9,10 @@
9
9
  "pi",
10
10
  "processes"
11
11
  ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/aliou/pi-extensions"
15
+ },
12
16
  "pi": {
13
17
  "extensions": [
14
18
  "./index.ts"
@@ -28,8 +32,8 @@
28
32
  "@sinclair/typebox": "^0.34.41"
29
33
  },
30
34
  "peerDependencies": {
31
- "@mariozechner/pi-ai": ">=0.49.0",
32
- "@mariozechner/pi-coding-agent": ">=0.49.0",
33
- "@mariozechner/pi-tui": ">=0.49.0"
35
+ "@mariozechner/pi-ai": "0.50.0",
36
+ "@mariozechner/pi-coding-agent": "0.50.0",
37
+ "@mariozechner/pi-tui": "0.50.0"
34
38
  }
35
- }
39
+ }
@@ -0,0 +1,20 @@
1
+ import type { ExecuteResult } from "../../constants";
2
+ import type { ProcessManager } from "../../manager";
3
+
4
+ export function executeClear(manager: ProcessManager): ExecuteResult {
5
+ const cleared = manager.clearFinished();
6
+ const message =
7
+ cleared > 0
8
+ ? `Cleared ${cleared} finished process(es)`
9
+ : "No finished processes to clear";
10
+
11
+ return {
12
+ content: [{ type: "text", text: message }],
13
+ details: {
14
+ action: "clear",
15
+ success: true,
16
+ message,
17
+ cleared,
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,49 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import type { ExecuteResult } from "../../constants";
3
+ import type { ProcessManager } from "../../manager";
4
+ import { executeClear } from "./clear";
5
+ import { executeKill } from "./kill";
6
+ import { executeList } from "./list";
7
+ import { executeLogs } from "./logs";
8
+ import { executeOutput } from "./output";
9
+ import { executeStart } from "./start";
10
+
11
+ interface ActionParams {
12
+ action: string;
13
+ command?: string;
14
+ name?: string;
15
+ id?: string;
16
+ notifyOnSuccess?: boolean;
17
+ notifyOnFailure?: boolean;
18
+ notifyOnKill?: boolean;
19
+ }
20
+
21
+ export async function executeAction(
22
+ params: ActionParams,
23
+ manager: ProcessManager,
24
+ ctx: ExtensionContext,
25
+ ): Promise<ExecuteResult> {
26
+ switch (params.action) {
27
+ case "start":
28
+ return executeStart(params, manager, ctx);
29
+ case "list":
30
+ return executeList(manager);
31
+ case "output":
32
+ return executeOutput(params, manager);
33
+ case "logs":
34
+ return executeLogs(params, manager);
35
+ case "kill":
36
+ return executeKill(params, manager);
37
+ case "clear":
38
+ return executeClear(manager);
39
+ default:
40
+ return {
41
+ content: [{ type: "text", text: `Unknown action: ${params.action}` }],
42
+ details: {
43
+ action: params.action,
44
+ success: false,
45
+ message: `Unknown action: ${params.action}`,
46
+ },
47
+ };
48
+ }
49
+ }