@aliou/pi-processes 0.4.5 → 0.4.7

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/src/manager.ts ADDED
@@ -0,0 +1,522 @@
1
+ import type { ChildProcess } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ import {
4
+ appendFileSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ statSync,
9
+ } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+
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";
22
+ import { spawnCommand } from "./utils/command-executor";
23
+
24
+ interface ManagedProcess extends ProcessInfo {
25
+ process: ChildProcess;
26
+ lastSignalSent: NodeJS.Signals | null;
27
+ combinedFile: string;
28
+ }
29
+
30
+ interface ProcessManagerOptions {
31
+ getConfiguredShellPath?: () => string | undefined;
32
+ }
33
+
34
+ export class ProcessManager {
35
+ private processes: Map<string, ManagedProcess> = new Map();
36
+ private counter = 0;
37
+ private logDir: string;
38
+ private events = new EventEmitter();
39
+ private watcher: ReturnType<typeof setInterval> | null = null;
40
+ private getConfiguredShellPath: () => string | undefined;
41
+
42
+ constructor(options?: ProcessManagerOptions) {
43
+ this.logDir = join(tmpdir(), `pi-processes-${Date.now()}`);
44
+ mkdirSync(this.logDir, { recursive: true });
45
+ this.getConfiguredShellPath =
46
+ options?.getConfiguredShellPath ?? (() => undefined);
47
+ }
48
+
49
+ onEvent(listener: (event: ManagerEvent) => void): () => void {
50
+ this.events.on("event", listener);
51
+ return () => this.events.off("event", listener);
52
+ }
53
+
54
+ private emit(event: ManagerEvent): void {
55
+ this.events.emit("event", event);
56
+ }
57
+
58
+ private transition(managed: ManagedProcess, next: ProcessStatus): void {
59
+ if (managed.status === next) return;
60
+ const prev = managed.status;
61
+ managed.status = next;
62
+
63
+ this.emit({
64
+ type: "process_status_changed",
65
+ info: this.toProcessInfo(managed),
66
+ prev,
67
+ });
68
+
69
+ if (next === "exited" || next === "killed") {
70
+ this.emit({ type: "process_ended", info: this.toProcessInfo(managed) });
71
+ }
72
+
73
+ this.ensureWatcherRunning();
74
+ this.stopWatcherIfIdle();
75
+ }
76
+
77
+ private ensureWatcherRunning(): void {
78
+ if (this.watcher) return;
79
+ if (!this.hasAliveishProcesses()) return;
80
+
81
+ this.watcher = setInterval(() => {
82
+ this.livenessTick();
83
+ }, 5000);
84
+ }
85
+
86
+ private stopWatcherIfIdle(): void {
87
+ if (!this.watcher) return;
88
+ if (this.hasAliveishProcesses()) return;
89
+
90
+ clearInterval(this.watcher);
91
+ this.watcher = null;
92
+ }
93
+
94
+ private hasAliveishProcesses(): boolean {
95
+ for (const p of this.processes.values()) {
96
+ if (LIVE_STATUSES.has(p.status)) return true;
97
+ }
98
+ return false;
99
+ }
100
+
101
+ private livenessTick(): void {
102
+ for (const managed of this.processes.values()) {
103
+ if (!LIVE_STATUSES.has(managed.status)) continue;
104
+ if (!managed.pid || managed.pid <= 0) continue;
105
+
106
+ const alive = isProcessGroupAlive(managed.pid);
107
+ if (alive) continue;
108
+
109
+ if (!managed.endTime) {
110
+ managed.endTime = Date.now();
111
+ }
112
+
113
+ if (managed.lastSignalSent) {
114
+ managed.success = false;
115
+ managed.exitCode = null;
116
+ this.transition(managed, "killed");
117
+ } else {
118
+ managed.success = false;
119
+ managed.exitCode = null;
120
+ this.transition(managed, "exited");
121
+ }
122
+ }
123
+ }
124
+
125
+ start(
126
+ name: string,
127
+ command: string,
128
+ cwd: string,
129
+ options?: StartOptions,
130
+ ): ProcessInfo {
131
+ const id = `proc_${++this.counter}`;
132
+ const stdoutFile = join(this.logDir, `${id}-stdout.log`);
133
+ const stderrFile = join(this.logDir, `${id}-stderr.log`);
134
+ const combinedFile = join(this.logDir, `${id}-combined.log`);
135
+
136
+ appendFileSync(stdoutFile, "");
137
+ appendFileSync(stderrFile, "");
138
+ appendFileSync(combinedFile, "");
139
+
140
+ const child = spawnCommand(command, cwd, this.getConfiguredShellPath());
141
+
142
+ child.unref();
143
+
144
+ const managed: ManagedProcess = {
145
+ id,
146
+ name,
147
+ pid: child.pid ?? -1,
148
+ command,
149
+ cwd,
150
+ startTime: Date.now(),
151
+ endTime: null,
152
+ status: "running",
153
+ exitCode: null,
154
+ success: null,
155
+ stdoutFile,
156
+ stderrFile,
157
+ combinedFile,
158
+ alertOnSuccess: options?.alertOnSuccess ?? false,
159
+ alertOnFailure: options?.alertOnFailure ?? true,
160
+ alertOnKill: options?.alertOnKill ?? false,
161
+ process: child,
162
+ lastSignalSent: null,
163
+ };
164
+
165
+ this.processes.set(id, managed);
166
+
167
+ if (!child.pid) {
168
+ try {
169
+ appendFileSync(stderrFile, "Spawn error: missing pid\n");
170
+ } catch {
171
+ // Ignore
172
+ }
173
+ managed.exitCode = -1;
174
+ managed.success = false;
175
+ managed.endTime = Date.now();
176
+ this.transition(managed, "exited");
177
+ return this.toProcessInfo(managed);
178
+ }
179
+
180
+ child.stdout?.on("data", (data: Buffer) => {
181
+ try {
182
+ appendFileSync(stdoutFile, data);
183
+ const lines = data.toString().split("\n");
184
+ // The last element after split is either empty (if data ended with \n)
185
+ // or a partial line. We write all parts with the prefix and newline.
186
+ const tagged = lines
187
+ .map((line, i) =>
188
+ i < lines.length - 1 ? `1:${line}\n` : line ? `1:${line}\n` : "",
189
+ )
190
+ .join("");
191
+ if (tagged) appendFileSync(combinedFile, tagged);
192
+ } catch {
193
+ // Ignore
194
+ }
195
+ });
196
+
197
+ child.stderr?.on("data", (data: Buffer) => {
198
+ try {
199
+ appendFileSync(stderrFile, data);
200
+ const lines = data.toString().split("\n");
201
+ const tagged = lines
202
+ .map((line, i) =>
203
+ i < lines.length - 1 ? `2:${line}\n` : line ? `2:${line}\n` : "",
204
+ )
205
+ .join("");
206
+ if (tagged) appendFileSync(combinedFile, tagged);
207
+ } catch {
208
+ // Ignore
209
+ }
210
+ });
211
+
212
+ child.on("close", (code, signal) => {
213
+ if (managed.endTime) return;
214
+
215
+ managed.exitCode = code;
216
+ managed.endTime = Date.now();
217
+ managed.success = code === 0;
218
+
219
+ if (signal) {
220
+ this.transition(managed, "killed");
221
+ } else {
222
+ this.transition(managed, "exited");
223
+ }
224
+ });
225
+
226
+ child.on("error", (err) => {
227
+ try {
228
+ appendFileSync(stderrFile, `Process error: ${err.message}\n`);
229
+ } catch {
230
+ // Ignore
231
+ }
232
+
233
+ if (!managed.endTime) {
234
+ managed.exitCode = -1;
235
+ managed.success = false;
236
+ managed.endTime = Date.now();
237
+ this.transition(managed, "exited");
238
+ }
239
+ });
240
+
241
+ this.emit({ type: "process_started", info: this.toProcessInfo(managed) });
242
+ this.ensureWatcherRunning();
243
+
244
+ return this.toProcessInfo(managed);
245
+ }
246
+
247
+ list(): ProcessInfo[] {
248
+ return Array.from(this.processes.values())
249
+ .map((p) => this.toProcessInfo(p))
250
+ .reverse();
251
+ }
252
+
253
+ get(id: string): ProcessInfo | null {
254
+ const managed = this.processes.get(id);
255
+ return managed ? this.toProcessInfo(managed) : null;
256
+ }
257
+
258
+ find(query: string): ProcessInfo | null {
259
+ const byId = this.processes.get(query);
260
+ if (byId) return this.toProcessInfo(byId);
261
+
262
+ const queryLower = query.toLowerCase();
263
+ for (const managed of this.processes.values()) {
264
+ if (managed.name.toLowerCase().includes(queryLower)) {
265
+ return this.toProcessInfo(managed);
266
+ }
267
+ if (managed.command.toLowerCase().includes(queryLower)) {
268
+ return this.toProcessInfo(managed);
269
+ }
270
+ }
271
+ return null;
272
+ }
273
+
274
+ getOutput(
275
+ id: string,
276
+ tailLines = 100,
277
+ ): { stdout: string[]; stderr: string[]; status: string } | null {
278
+ const managed = this.processes.get(id);
279
+ if (!managed) return null;
280
+
281
+ return {
282
+ stdout: this.readTailLines(managed.stdoutFile, tailLines),
283
+ stderr: this.readTailLines(managed.stderrFile, tailLines),
284
+ status: managed.status,
285
+ };
286
+ }
287
+
288
+ getCombinedOutput(
289
+ id: string,
290
+ tailLines = 100,
291
+ ): { type: "stdout" | "stderr"; text: string }[] | null {
292
+ const managed = this.processes.get(id);
293
+ if (!managed) return null;
294
+
295
+ const rawLines = this.readTailLines(managed.combinedFile, tailLines);
296
+ return rawLines.map((line) => {
297
+ if (line.startsWith("2:")) {
298
+ return { type: "stderr", text: line.slice(2) };
299
+ }
300
+ // Default to stdout (handles "1:" prefix and any malformed lines).
301
+ return {
302
+ type: "stdout",
303
+ text: line.startsWith("1:") ? line.slice(2) : line,
304
+ };
305
+ });
306
+ }
307
+
308
+ getFullOutput(id: string): { stdout: string; stderr: string } | null {
309
+ const managed = this.processes.get(id);
310
+ if (!managed) return null;
311
+
312
+ try {
313
+ return {
314
+ stdout: readFileSync(managed.stdoutFile, "utf-8"),
315
+ stderr: readFileSync(managed.stderrFile, "utf-8"),
316
+ };
317
+ } catch {
318
+ return { stdout: "", stderr: "" };
319
+ }
320
+ }
321
+
322
+ getLogFiles(id: string): { stdoutFile: string; stderrFile: string } | null {
323
+ const managed = this.processes.get(id);
324
+ if (!managed) return null;
325
+ return {
326
+ stdoutFile: managed.stdoutFile,
327
+ stderrFile: managed.stderrFile,
328
+ };
329
+ }
330
+
331
+ async kill(
332
+ id: string,
333
+ opts?: { signal?: NodeJS.Signals; timeoutMs?: number },
334
+ ): Promise<KillResult> {
335
+ const managed = this.processes.get(id);
336
+ if (!managed) {
337
+ return {
338
+ ok: false,
339
+ info: {
340
+ id,
341
+ name: "(unknown)",
342
+ pid: -1,
343
+ command: "",
344
+ cwd: "",
345
+ startTime: 0,
346
+ endTime: null,
347
+ status: "exited",
348
+ exitCode: null,
349
+ success: false,
350
+ stdoutFile: "",
351
+ stderrFile: "",
352
+ alertOnSuccess: false,
353
+ alertOnFailure: true,
354
+ alertOnKill: false,
355
+ },
356
+ reason: "not_found",
357
+ };
358
+ }
359
+
360
+ const signal = opts?.signal ?? "SIGTERM";
361
+ const timeoutMs = opts?.timeoutMs ?? 3000;
362
+
363
+ managed.alertOnKill = false;
364
+
365
+ if (!LIVE_STATUSES.has(managed.status)) {
366
+ return { ok: true, info: this.toProcessInfo(managed) };
367
+ }
368
+
369
+ this.transition(managed, "terminating");
370
+
371
+ try {
372
+ killProcessGroup(managed.pid, signal);
373
+ managed.lastSignalSent = signal;
374
+ } catch (error) {
375
+ const err = error as NodeJS.ErrnoException;
376
+ if (err.code !== "EPERM") {
377
+ return {
378
+ ok: false,
379
+ info: this.toProcessInfo(managed),
380
+ reason: "error",
381
+ };
382
+ }
383
+ }
384
+
385
+ const graceMs = signal === "SIGKILL" ? 200 : timeoutMs;
386
+
387
+ await new Promise((r) => setTimeout(r, graceMs));
388
+
389
+ const alive = isProcessGroupAlive(managed.pid);
390
+
391
+ if (alive) {
392
+ this.transition(managed, "terminate_timeout");
393
+ return {
394
+ ok: false,
395
+ info: this.toProcessInfo(managed),
396
+ reason: "timeout",
397
+ };
398
+ }
399
+
400
+ if (!managed.endTime) {
401
+ managed.endTime = Date.now();
402
+ managed.exitCode = null;
403
+ managed.success = false;
404
+ }
405
+
406
+ this.transition(managed, "killed");
407
+ return { ok: true, info: this.toProcessInfo(managed) };
408
+ }
409
+
410
+ clearFinished(): number {
411
+ let cleared = 0;
412
+ for (const [id, managed] of this.processes) {
413
+ if (LIVE_STATUSES.has(managed.status)) {
414
+ continue;
415
+ }
416
+
417
+ try {
418
+ rmSync(managed.stdoutFile, { force: true });
419
+ rmSync(managed.stderrFile, { force: true });
420
+ rmSync(managed.combinedFile, { force: true });
421
+ } catch {
422
+ // Ignore
423
+ }
424
+
425
+ this.processes.delete(id);
426
+ cleared++;
427
+ }
428
+
429
+ if (cleared > 0) {
430
+ this.emit({ type: "processes_changed" });
431
+ }
432
+
433
+ this.stopWatcherIfIdle();
434
+ return cleared;
435
+ }
436
+
437
+ shutdownKillAll(): void {
438
+ for (const p of this.processes.values()) {
439
+ if (!LIVE_STATUSES.has(p.status)) continue;
440
+ try {
441
+ killProcessGroup(p.pid, "SIGKILL");
442
+ } catch {
443
+ // Ignore - process may already be dead
444
+ }
445
+ }
446
+ }
447
+
448
+ stopWatcher(): void {
449
+ if (this.watcher) {
450
+ clearInterval(this.watcher);
451
+ this.watcher = null;
452
+ }
453
+ }
454
+
455
+ cleanup(): void {
456
+ this.stopWatcher();
457
+
458
+ for (const p of this.processes.values()) {
459
+ if (!LIVE_STATUSES.has(p.status)) continue;
460
+ try {
461
+ killProcessGroup(p.pid, "SIGKILL");
462
+ } catch {
463
+ // Ignore
464
+ }
465
+ }
466
+
467
+ try {
468
+ rmSync(this.logDir, { recursive: true, force: true });
469
+ } catch {
470
+ // Ignore
471
+ }
472
+ }
473
+
474
+ getFileSize(id: string): { stdout: number; stderr: number } | null {
475
+ const managed = this.processes.get(id);
476
+ if (!managed) return null;
477
+
478
+ try {
479
+ return {
480
+ stdout: statSync(managed.stdoutFile).size,
481
+ stderr: statSync(managed.stderrFile).size,
482
+ };
483
+ } catch {
484
+ return { stdout: 0, stderr: 0 };
485
+ }
486
+ }
487
+
488
+ private readTailLines(filePath: string, lines: number): string[] {
489
+ try {
490
+ const content = readFileSync(filePath, "utf-8");
491
+ const allLines = content.split("\n");
492
+ if (allLines.length > 0 && allLines[allLines.length - 1] === "") {
493
+ allLines.pop();
494
+ }
495
+ return allLines.slice(-lines);
496
+ } catch {
497
+ return [];
498
+ }
499
+ }
500
+
501
+ private toProcessInfo(managed: ManagedProcess): ProcessInfo {
502
+ return {
503
+ id: managed.id,
504
+ name: managed.name,
505
+ pid: managed.pid,
506
+ command: managed.command,
507
+ cwd: managed.cwd,
508
+ startTime: managed.startTime,
509
+ endTime: managed.endTime,
510
+ status: managed.status,
511
+ exitCode: managed.exitCode,
512
+ success: managed.success,
513
+ stdoutFile: managed.stdoutFile,
514
+ stderrFile: managed.stderrFile,
515
+ alertOnSuccess: managed.alertOnSuccess,
516
+ alertOnFailure: managed.alertOnFailure,
517
+ alertOnKill: managed.alertOnKill,
518
+ };
519
+ }
520
+ }
521
+
522
+ export type { ProcessInfo, ProcessStatus, ManagerEvent, KillResult };
@@ -0,0 +1,19 @@
1
+ #!/bin/bash
2
+ # Test script that simulates a crash (exit code 137 - like SIGKILL)
3
+ # Usage: ./test-exit-crash.sh [seconds]
4
+
5
+ WAIT_SECONDS=${1:-17}
6
+
7
+ echo "Starting unstable task..."
8
+ echo "Will crash in ${WAIT_SECONDS} seconds"
9
+
10
+ for i in $(seq 1 $WAIT_SECONDS); do
11
+ echo "[$(date '+%H:%M:%S')] Running... ($i/$WAIT_SECONDS)"
12
+ if [ $i -eq $((WAIT_SECONDS - 1)) ]; then
13
+ echo "[WARN] Memory pressure detected" >&2
14
+ fi
15
+ sleep 1
16
+ done
17
+
18
+ echo "FATAL: Segmentation fault (core dumped)" >&2
19
+ exit 137
@@ -0,0 +1,17 @@
1
+ #!/bin/bash
2
+ # Test script that exits with failure (exit code 1)
3
+ # Usage: ./test-exit-failure.sh [seconds]
4
+
5
+ WAIT_SECONDS=${1:-15}
6
+
7
+ echo "Starting failing task..."
8
+ echo "Will fail in ${WAIT_SECONDS} seconds"
9
+
10
+ for i in $(seq 1 $WAIT_SECONDS); do
11
+ echo "[$(date '+%H:%M:%S')] Processing... ($i/$WAIT_SECONDS)"
12
+ sleep 1
13
+ done
14
+
15
+ echo "ERROR: Task failed!" >&2
16
+ echo "Something went wrong!" >&2
17
+ exit 1
@@ -0,0 +1,16 @@
1
+ #!/bin/bash
2
+ # Test script that exits successfully (exit code 0)
3
+ # Usage: ./test-exit-success.sh [seconds]
4
+
5
+ WAIT_SECONDS=${1:-13}
6
+
7
+ echo "Starting successful task..."
8
+ echo "Will complete in ${WAIT_SECONDS} seconds"
9
+
10
+ for i in $(seq 1 $WAIT_SECONDS); do
11
+ echo "[$(date '+%H:%M:%S')] Working... ($i/$WAIT_SECONDS)"
12
+ sleep 1
13
+ done
14
+
15
+ echo "Task completed successfully!"
16
+ exit 0
@@ -0,0 +1,28 @@
1
+ #!/bin/bash
2
+ # Test script for processes extension
3
+ # Writes 80 characters every second, empty line every 10 seconds
4
+
5
+ counter=0
6
+ while true; do
7
+ counter=$((counter + 1))
8
+
9
+ # Generate 80 characters: timestamp + padding
10
+ timestamp=$(date '+%H:%M:%S')
11
+ line=$(printf "[%s] Line %05d: " "$timestamp" "$counter")
12
+ # Pad to 80 chars with random chars
13
+ padding_len=$((80 - ${#line}))
14
+ padding=$(head -c $padding_len /dev/urandom | LC_ALL=C tr -dc 'a-zA-Z0-9' 2>/dev/null || printf '%*s' "$padding_len" '' | tr ' ' 'x')
15
+ echo "${line}${padding}"
16
+
17
+ # Every 10 seconds, print an empty line
18
+ if [ $((counter % 10)) -eq 0 ]; then
19
+ echo ""
20
+ fi
21
+
22
+ # Every 5 lines, write something to stderr
23
+ if [ $((counter % 5)) -eq 0 ]; then
24
+ echo "[WARN] Counter reached $counter" >&2
25
+ fi
26
+
27
+ sleep 1
28
+ done
@@ -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
+ alertOnSuccess?: boolean;
17
+ alertOnFailure?: boolean;
18
+ alertOnKill?: 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
+ }